Owns the second-opinion code-smell scan. Layered on top of Biome (which owns formatting + the bulk of lint) and Stylelint (which owns CSS). Sonarjs's job is the narrow set of patterns Biome doesn't cover: cognitive complexity, dead-branch detection, redundant-assignment, nested-conditional/function depth, and a small set of bug patterns.
This is a local lint pass. No SonarQube server, no sonar-scanner CLI, no host.url / project token. The full Sonar product would require a self-hosted SonarQube instance or SonarCloud account; dean-stack deliberately stops at the eslint-plugin layer because the GitHub Pages stack has no backend and the gate is local-first.
When to invoke
- Authoring or editing
apps/<name>/eslint.sonar.config.mjs.
- Diagnosing a
bun run check:sonar (or .github/workflows/sonarjs.yml) failure.
- Deciding whether a sonarjs finding is a real bug, an intrinsic domain pattern, or a candidate for rule-policy adjustment.
- Adding a new sonarjs rule disable or a new rule override (must include rationale).
- Triaging a freshly-discovered category of finding (the user wants to add sonarjs to a new app, or upgrade the plugin and re-baseline).
Owns
- The
eslint-plugin-sonarjs flat config file at apps/<name>/eslint.sonar.config.mjs.
- The calibrated rule policy: which rules are off, which thresholds are raised, which are scoped to specific file globs, and the rationale comment for every deviation from the recommended preset.
- The
check:sonar workspace script and its --max-warnings 0 exit contract.
- The
.github/workflows/sonarjs.yml workflow shape (PR-only, head_ref pinned, bun --filter=@dean-stack/web run check:sonar).
- The refactor patterns for the rules that fire most often in this codebase (table below).
Defers to
biome — for formatter + the bulk of lint (.ts/.tsx/.js/.json). Sonarjs MUST NOT overlap with Biome — that's why the config loads ONLY sonarjs.configs.recommended, no @eslint/js recommended rules.
stylelint — for everything .css. Sonarjs is JS/TS only.
react-doctor — for React-specific patterns (heading weight, useState(prop), barrel imports, etc.). Sonarjs and react-doctor have separate ownership; both run in the gate.
fallow — for cross-cutting structural checks (unused files/exports/types/deps, circular dependencies, boundary violations). Sonarjs is rule-based per-file; fallow is graph-based.
turborepo — for when check:sonar runs in the gate sequence (it's a per-app turbo task; the root check / check:fast scripts depend on it).
Dean-stack rules
- Pillar 4 (CLI-gate-first) means:
eslint --config eslint.sonar.config.mjs --max-warnings 0 exits non-zero on any finding, and any warning is a failure (zero-warning policy). Local pre-push gate (bun run check:fast) and CI both run it. The IDE is not the source of truth — the CLI is.
- NO server side. Never reach for
sonar-scanner-cli, sonarqube-scanner, or @sonar/scan; they all require a SonarQube/SonarCloud server. The local eslint-plugin gives ~95% of the rule signal with 0% of the infra.
- NO Biome overlap. The flat config loads ONLY
sonarjs.configs.recommended. Do NOT add @eslint/js recommended, typescript-eslint's recommended sets, or any other eslint plugin. Dual-linter drift with Biome is the failure mode you're trying to avoid; keeping the surface narrow is the protection.
- Every rule deviation from
sonarjs.configs.recommended MUST carry a rationale comment in eslint.sonar.config.mjs. The current deviations and their reasons live there — read them before adding a new one.
- Disable comments are a code smell (per AGENTS.md). If you find yourself reaching for
// eslint-disable-next-line sonarjs/<rule>, refactor instead — the patterns table below is the menu of canonical fixes.
Calibrated rule policy (for dean-stack's domain)
These deviations from sonarjs.configs.recommended are deliberate and live in apps/web/eslint.sonar.config.mjs with rationale comments. Read those before changing the policy.
| Rule |
Setting |
Why this codebase deviates |
sonarjs/pseudo-random |
off (global) |
Math.random drives card shuffles, particle jitter, attack-fx variation, audio detune. Nothing security-sensitive. Swapping to crypto.getRandomValues would add ceremony without buying anything. |
sonarjs/void-use |
off (global) |
Fire-and-forget Promises in sound/use-sound.ts (audio unlock listeners), sound/player.ts (one-shot SFX dispatch), state/db.ts (BroadcastChannel + IDB persistence callbacks), and attack-fx/kinds/vortex.ts are intentional. void doThing() documents that the caller doesn't await; .catch(() => undefined) is noisier and just as untyped. |
sonarjs/cognitive-complexity |
["error", 50] |
Game state machines (auto-assist.applyAutoAssist, hints.generateHints, deal.dealRound, AddingGame route component, drag pointer handlers, dive-in/round-complete ticker callbacks) are intrinsically branchy on comparator/operator/shape. The default 15 flagged 11 sites all in this category. Mechanically splitting them produces helper soup that's harder to read; 50 still flags egregious cases. |
sonarjs/no-nested-functions |
off for tests/**/*.ts |
Playwright IDB seeding uses promise-wrapped indexedDB.open + transaction-completion callbacks (addInitScript(async () => { ... openDb().then(...) })). The depth limit can't accommodate IDB's required callback shape; flattening would mean inventing a Promise-returning IDB helper just for tests. App code keeps the rule. |
Refactor patterns — apply these instead of disabling
When sonarjs fires on app code, prefer the canonical refactor over a disable comment. Each pattern below has a worked example committed to the repo.
| Rule |
Refactor |
Worked example |
sonarjs/no-nested-conditional |
Extract a single-purpose helper (comparisonGlyph, dotClass, firstLocked); use a lookup table (COMPARATOR_VERB: Record<Comparator, string>); split a nested ?: chain into independent && blocks; wrap a multi-branch expression in an IIFE that returns the value. |
apps/web/app/components/hint-tooltip/index.tsx (comparisonGlyph); apps/web/app/games/adding-game/auto-assist.ts (firstLocked); apps/web/app/games/adding-game/hints.ts (COMPARATOR_VERB); apps/web/app/routes/adding-game.tsx (independent && blocks). |
sonarjs/no-redundant-assignments |
Restructure the if/else ladder so the initial value of the variable IS the fallback (drop the redundant else var = initial;). |
apps/web/app/components/round-complete-fx/index.tsx (alpha = 0 initial, then if (t >= 400 && t < 1600) alpha = ...). |
sonarjs/no-unused-vars (e.g. unused destructured key) |
Switch to the iterator that doesn't expose the unused half — Object.values() instead of for (const [_id, entry] of Object.entries()). |
apps/web/app/sound/registry.test.ts. |
sonarjs/concise-regex |
Use the canonical character-class shortcut (\w for [a-zA-Z0-9_], \d for [0-9], \s for whitespace). |
apps/web/app/env.ts (Twitter handle regex). |
sonarjs/no-nested-functions (in app code) |
Hoist the inner function declaration out of the closure; if it needs closure state, use a Promise-returning helper at module scope and call it. (Tests/ are exempt — see policy table.) |
apps/web/tests/adding-game-poster-progression.app.spec.ts (openDb + awaitTx Promise wrappers). |
sonarjs/cognitive-complexity (above the 50 threshold, when raised) |
Extract sub-state-machines as their own useReducers, extract pure helpers to module scope, replace big switches with Record<Kind, Runner> dispatch tables. (Same pattern fallow's circular-deps fix for attack-fx/runtime.ts used.) |
apps/web/app/games/adding-game/attack-fx/runtime.ts (KIND_RUNNERS dispatch table replacing a 10-case switch). |
Anti-patterns
- Adding
eslint-plugin-react, eslint-plugin-import, @typescript-eslint/recommended, or @eslint/js to eslint.sonar.config.mjs. Sonar config is sonar-only; adding more plugins re-introduces dual-linter drift with Biome. If you want React-specific signal, that's react-doctor's job.
sonar-scanner, sonarqube-scanner, @sonar/scan, host.url, sonar.token. All require a SonarQube/SonarCloud server. dean-stack is server-less.
- Disabling a rule globally without a rationale comment. Future-you (or a future agent) will not know whether the disable is load-bearing. Always inline the WHY.
- Inline
// eslint-disable-next-line sonarjs/<rule> for no-nested-conditional, no-nested-functions, or cognitive-complexity in app code. These have canonical refactors (see the table). Suppressions in app code accumulate; one-off becomes pattern.
- Adding sonarjs as a separate
"check:sonar" step at the root package.json level instead of as a per-app turbo task. It's a per-workspace lint; let turborepo orchestrate it via the check:sonar task in turbo.json so cache + filter + future workspace fan-out work.
- Pinning the
.github/workflows/sonarjs.yml checkout to the merge SHA (the default). Use ref: ${{ github.head_ref }} so the scan reflects the PR branch's actual state, not a synthetic merge with main. Same pattern as react-doctor.yml and fallow.yml.
Setup recipe (new app)
- Add devDeps to
apps/<name>/package.json:"eslint": "^10.3.0",
"eslint-plugin-sonarjs": "^4.0.3",
"typescript-eslint": "^8.59.2"
- Copy
apps/web/eslint.sonar.config.mjs (or scaffold from turbo/generators/templates/app/eslint.sonar.config.mjs).
- Add the
check:sonar script to apps/<name>/package.json:"check:sonar": "eslint --config eslint.sonar.config.mjs --no-warn-ignored --max-warnings 0 \"app/**/*.{ts,tsx}\" \"tests/**/*.ts\" \"scripts/**/*.ts\""
- Add the task to
turbo.json (already done — task name is check:sonar, no dependsOn):"check:sonar": { "outputs": [] }
- The root
check and check:fast scripts already include check:sonar in their turbo dependsOn. New apps automatically get gated.
- Run
bun --filter=@dean-stack/<name> run check:sonar once and triage findings: real bugs first, then refactor patterns from the table above, then policy adjustments (with rationale comments) for irreducible cases.
Plugin pin
eslint-plugin-sonarjs ^4.0.3 (LGPL-3.0). The plugin is bundled with the recommended preset; no peer-plugin dance required. The eslint and typescript-eslint peers must be installed at the consuming workspace's package.json (workspace hoisting won't satisfy ESLint's plugin-resolution path).
1---2name: eslint-plugin-sonarjs3description: Local-only second-opinion lint pass via `eslint-plugin-sonarjs` — code-smell, cognitive-complexity, and bug-pattern rules layered on top of Biome (which owns formatting + the bulk of lint). NO SonarQube server, NO `sonar-scanner`, NO project token. Runs as `bun run check:sonar` (the workspace task) and as the standalone `.github/workflows/sonarjs.yml` workflow. Triggers on: sonar, sonarjs, eslint-plugin-sonarjs, cognitive-complexity, pseudo-random, no-nested-conditional, no-nested-functions, void-use, second-opinion lint, eslint.sonar.config.mjs.4license: MIT5---67Owns the second-opinion code-smell scan. Layered on top of Biome (which owns formatting + the bulk of lint) and Stylelint (which owns CSS). Sonarjs's job is the narrow set of patterns Biome doesn't cover: cognitive complexity, dead-branch detection, redundant-assignment, nested-conditional/function depth, and a small set of bug patterns.89**This is a local lint pass. No SonarQube server, no `sonar-scanner` CLI, no `host.url` / project token.** The full Sonar product would require a self-hosted SonarQube instance or SonarCloud account; dean-stack deliberately stops at the eslint-plugin layer because the GitHub Pages stack has no backend and the gate is local-first.1011## When to invoke12- Authoring or editing `apps/<name>/eslint.sonar.config.mjs`.13- Diagnosing a `bun run check:sonar` (or `.github/workflows/sonarjs.yml`) failure.14- Deciding whether a sonarjs finding is a real bug, an intrinsic domain pattern, or a candidate for rule-policy adjustment.15- Adding a new sonarjs rule disable or a new rule override (must include rationale).16- Triaging a freshly-discovered category of finding (the user wants to add sonarjs to a new app, or upgrade the plugin and re-baseline).1718## Owns19- The `eslint-plugin-sonarjs` flat config file at `apps/<name>/eslint.sonar.config.mjs`.20- The calibrated rule policy: which rules are off, which thresholds are raised, which are scoped to specific file globs, and the **rationale comment** for every deviation from the recommended preset.21- The `check:sonar` workspace script and its `--max-warnings 0` exit contract.22- The `.github/workflows/sonarjs.yml` workflow shape (PR-only, `head_ref` pinned, `bun --filter=@dean-stack/web run check:sonar`).23- The refactor patterns for the rules that fire most often in this codebase (table below).2425## Defers to26- `biome` — for formatter + the bulk of lint (`.ts`/`.tsx`/`.js`/`.json`). Sonarjs MUST NOT overlap with Biome — that's why the config loads ONLY `sonarjs.configs.recommended`, no `@eslint/js` recommended rules.27- `stylelint` — for everything `.css`. Sonarjs is JS/TS only.28- `react-doctor` — for React-specific patterns (heading weight, `useState(prop)`, barrel imports, etc.). Sonarjs and react-doctor have separate ownership; both run in the gate.29- `fallow` — for cross-cutting structural checks (unused files/exports/types/deps, circular dependencies, boundary violations). Sonarjs is rule-based per-file; fallow is graph-based.30- `turborepo` — for *when* `check:sonar` runs in the gate sequence (it's a per-app turbo task; the root `check` / `check:fast` scripts depend on it).3132## Dean-stack rules3334- **Pillar 4 (CLI-gate-first) means**: `eslint --config eslint.sonar.config.mjs --max-warnings 0` exits non-zero on any finding, and any warning is a failure (zero-warning policy). Local pre-push gate (`bun run check:fast`) and CI both run it. The IDE is not the source of truth — the CLI is.35- **NO server side.** Never reach for `sonar-scanner-cli`, `sonarqube-scanner`, or `@sonar/scan`; they all require a SonarQube/SonarCloud server. The local eslint-plugin gives ~95% of the rule signal with 0% of the infra.36- **NO Biome overlap.** The flat config loads ONLY `sonarjs.configs.recommended`. Do NOT add `@eslint/js` recommended, `typescript-eslint`'s recommended sets, or any other eslint plugin. Dual-linter drift with Biome is the failure mode you're trying to avoid; keeping the surface narrow is the protection.37- **Every rule deviation from `sonarjs.configs.recommended` MUST carry a rationale comment** in `eslint.sonar.config.mjs`. The current deviations and their reasons live there — read them before adding a new one.38- **Disable comments are a code smell** (per AGENTS.md). If you find yourself reaching for `// eslint-disable-next-line sonarjs/<rule>`, refactor instead — the patterns table below is the menu of canonical fixes.3940## Calibrated rule policy (for dean-stack's domain)4142These deviations from `sonarjs.configs.recommended` are deliberate and live in `apps/web/eslint.sonar.config.mjs` with rationale comments. Read those before changing the policy.4344| Rule | Setting | Why this codebase deviates |45|---|---|---|46| `sonarjs/pseudo-random` | `off` (global) | Math.random drives card shuffles, particle jitter, attack-fx variation, audio detune. Nothing security-sensitive. Swapping to `crypto.getRandomValues` would add ceremony without buying anything. |47| `sonarjs/void-use` | `off` (global) | Fire-and-forget Promises in `sound/use-sound.ts` (audio unlock listeners), `sound/player.ts` (one-shot SFX dispatch), `state/db.ts` (BroadcastChannel + IDB persistence callbacks), and `attack-fx/kinds/vortex.ts` are intentional. `void doThing()` documents that the caller doesn't await; `.catch(() => undefined)` is noisier and just as untyped. |48| `sonarjs/cognitive-complexity` | `["error", 50]` | Game state machines (`auto-assist.applyAutoAssist`, `hints.generateHints`, `deal.dealRound`, `AddingGame` route component, drag pointer handlers, `dive-in/round-complete` ticker callbacks) are intrinsically branchy on comparator/operator/shape. The default `15` flagged 11 sites all in this category. Mechanically splitting them produces helper soup that's harder to read; `50` still flags egregious cases. |49| `sonarjs/no-nested-functions` | `off` for `tests/**/*.ts` | Playwright IDB seeding uses promise-wrapped `indexedDB.open` + transaction-completion callbacks (`addInitScript(async () => { ... openDb().then(...) })`). The depth limit can't accommodate IDB's required callback shape; flattening would mean inventing a Promise-returning IDB helper just for tests. App code keeps the rule. |5051## Refactor patterns — apply these instead of disabling5253When sonarjs fires on app code, prefer the canonical refactor over a disable comment. Each pattern below has a worked example committed to the repo.5455| Rule | Refactor | Worked example |56|---|---|---|57| `sonarjs/no-nested-conditional` | Extract a single-purpose helper (`comparisonGlyph`, `dotClass`, `firstLocked`); use a lookup table (`COMPARATOR_VERB: Record<Comparator, string>`); split a nested `?:` chain into independent `&&` blocks; wrap a multi-branch expression in an IIFE that returns the value. | `apps/web/app/components/hint-tooltip/index.tsx` (`comparisonGlyph`); `apps/web/app/games/adding-game/auto-assist.ts` (`firstLocked`); `apps/web/app/games/adding-game/hints.ts` (`COMPARATOR_VERB`); `apps/web/app/routes/adding-game.tsx` (independent `&&` blocks). |58| `sonarjs/no-redundant-assignments` | Restructure the `if`/`else` ladder so the initial value of the variable IS the fallback (drop the redundant `else var = initial;`). | `apps/web/app/components/round-complete-fx/index.tsx` (`alpha = 0` initial, then `if (t >= 400 && t < 1600) alpha = ...`). |59| `sonarjs/no-unused-vars` (e.g. unused destructured key) | Switch to the iterator that doesn't expose the unused half — `Object.values()` instead of `for (const [_id, entry] of Object.entries())`. | `apps/web/app/sound/registry.test.ts`. |60| `sonarjs/concise-regex` | Use the canonical character-class shortcut (`\w` for `[a-zA-Z0-9_]`, `\d` for `[0-9]`, `\s` for whitespace). | `apps/web/app/env.ts` (Twitter handle regex). |61| `sonarjs/no-nested-functions` (in app code) | Hoist the inner function declaration out of the closure; if it needs closure state, use a Promise-returning helper at module scope and call it. (Tests/ are exempt — see policy table.) | `apps/web/tests/adding-game-poster-progression.app.spec.ts` (`openDb` + `awaitTx` Promise wrappers). |62| `sonarjs/cognitive-complexity` (above the 50 threshold, when raised) | Extract sub-state-machines as their own `useReducer`s, extract pure helpers to module scope, replace big switches with `Record<Kind, Runner>` dispatch tables. (Same pattern fallow's circular-deps fix for `attack-fx/runtime.ts` used.) | `apps/web/app/games/adding-game/attack-fx/runtime.ts` (`KIND_RUNNERS` dispatch table replacing a 10-case switch). |6364## Anti-patterns6566- **Adding `eslint-plugin-react`, `eslint-plugin-import`, `@typescript-eslint/recommended`, or `@eslint/js` to `eslint.sonar.config.mjs`.** Sonar config is sonar-only; adding more plugins re-introduces dual-linter drift with Biome. If you want React-specific signal, that's `react-doctor`'s job.67- **`sonar-scanner`, `sonarqube-scanner`, `@sonar/scan`, `host.url`, `sonar.token`.** All require a SonarQube/SonarCloud server. dean-stack is server-less.68- **Disabling a rule globally without a rationale comment.** Future-you (or a future agent) will not know whether the disable is load-bearing. Always inline the WHY.69- **Inline `// eslint-disable-next-line sonarjs/<rule>` for `no-nested-conditional`, `no-nested-functions`, or `cognitive-complexity` in app code.** These have canonical refactors (see the table). Suppressions in app code accumulate; one-off becomes pattern.70- **Adding sonarjs as a separate `"check:sonar"` step at the root `package.json` level instead of as a per-app turbo task.** It's a per-workspace lint; let turborepo orchestrate it via the `check:sonar` task in `turbo.json` so cache + filter + future workspace fan-out work.71- **Pinning the `.github/workflows/sonarjs.yml` checkout to the merge SHA (the default).** Use `ref: ${{ github.head_ref }}` so the scan reflects the PR branch's actual state, not a synthetic merge with main. Same pattern as `react-doctor.yml` and `fallow.yml`.7273## Setup recipe (new app)74751. Add devDeps to `apps/<name>/package.json`:76 ```json77 "eslint": "^10.3.0",78 "eslint-plugin-sonarjs": "^4.0.3",79 "typescript-eslint": "^8.59.2"80 ```812. Copy `apps/web/eslint.sonar.config.mjs` (or scaffold from `turbo/generators/templates/app/eslint.sonar.config.mjs`).823. Add the `check:sonar` script to `apps/<name>/package.json`:83 ```json84 "check:sonar": "eslint --config eslint.sonar.config.mjs --no-warn-ignored --max-warnings 0 \"app/**/*.{ts,tsx}\" \"tests/**/*.ts\" \"scripts/**/*.ts\""85 ```864. Add the task to `turbo.json` (already done — task name is `check:sonar`, no `dependsOn`):87 ```json88 "check:sonar": { "outputs": [] }89 ```905. The root `check` and `check:fast` scripts already include `check:sonar` in their turbo `dependsOn`. New apps automatically get gated.916. Run `bun --filter=@dean-stack/<name> run check:sonar` once and triage findings: real bugs first, then refactor patterns from the table above, then policy adjustments (with rationale comments) for irreducible cases.9293## Plugin pin9495`eslint-plugin-sonarjs` `^4.0.3` (LGPL-3.0). The plugin is bundled with the recommended preset; no peer-plugin dance required. The `eslint` and `typescript-eslint` peers must be installed at the consuming workspace's `package.json` (workspace hoisting won't satisfy ESLint's plugin-resolution path).