ESLint Configuration
The shared ESLint flat config -- TypeScript strict rules, naming conventions, complexity limits, import sorting, code smell detection, and SDK boundary enforcement.
Trovella uses ESLint 9 with the flat config format. All configuration lives in @repo/eslint-config, and each package in the monorepo spreads one of the shared presets into its own eslint.config.js.
Preset Architecture
The base config (packages/config-eslint/base.js) contains every rule. The library and Next.js presets extend it:
base.js <- core rules, all plugins
library.js <- re-exports base (reserved for future library-specific rules)
next.js <- base + eslint-config-next
Every preset ends with eslint-config-prettier to disable formatting rules that conflict with Prettier.
Plugins
| Plugin | Purpose |
|---|---|
typescript-eslint (strict type-checked) | Type-aware TypeScript rules |
eslint-plugin-simple-import-sort | Deterministic import/export ordering |
eslint-plugin-sonarjs | Code smell detection (cherry-picked rules) |
eslint-plugin-jsdoc | Require JSDoc on exported functions/classes/types |
eslint-plugin-tsdoc | Validate TSDoc syntax in doc comments |
eslint-plugin-unicorn | Expiring TODO enforcement |
TypeScript Strict Rules
These rules target common AI-generated code quality issues:
| Rule | Setting | Why |
|---|---|---|
no-unused-vars | Error (ignore _ prefix) | Prevents dead parameters and variables |
no-explicit-any | Error | Forces proper typing; any defeats the type system |
no-floating-promises | Error | Catches unhandled async operations |
no-misused-promises | Error | Prevents passing promises where sync values are expected |
consistent-type-imports | Error, inline-type-imports | Ensures import { type Foo } syntax for type-only imports |
only-throw-error | Error | Only Error subclasses (TRPCError, AppError) may be thrown |
The base config extends tseslint.configs.strictTypeChecked, which enables the full set of type-aware rules beyond the ones listed above.
Naming Conventions
The @typescript-eslint/naming-convention rule enforces consistent casing across the codebase:
| Selector | Allowed formats | Notes |
|---|---|---|
| Default | camelCase | Baseline for all identifiers |
| Variables | camelCase, PascalCase, UPPER_CASE | PascalCase for React components, UPPER_CASE for constants |
| Destructured variables | Any | Exempt -- external APIs and env vars use varied casing |
__ prefix variables | Any | Node.js shims like __dirname |
| Functions | camelCase, PascalCase | PascalCase for React components |
Exported functions matching GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS | UPPER_CASE | Next.js HTTP route handlers |
| Parameters | camelCase, PascalCase | Leading _ allowed for unused params |
| Object literal properties | Any | Exempt -- Drizzle schemas, API payloads, config objects |
| Type properties | camelCase, snake_case | snake_case for external API boundary types (Anthropic, Typesense) |
| Quoted properties | Any | Exempt -- required by the runtime |
| Type-like (types, interfaces, classes, enums) | PascalCase | Standard TypeScript convention |
| Type parameters | PascalCase with T prefix | TResult, TItem, not Result or T |
| Imports | camelCase, PascalCase | PascalCase for React components and classes |
Complexity Limits
| Rule | Limit | Scope |
|---|---|---|
max-lines | 400 (skip blanks/comments) | File |
max-lines-per-function | 100 (skip blanks/comments) | Function |
complexity (cyclomatic) | 20 | Function |
sonarjs/cognitive-complexity | 20 | Function |
max-depth | 4 | Nesting |
max-params | 4 | Function signature |
Relaxed Files
Complexity and size limits are turned off for files that are legitimately large:
**/seeds/**-- seed data files**/migrations/**-- generated migration SQL**/schema/**-- Drizzle schema definitions**/__tests__/**,**/*.test.{ts,tsx},**/*.spec.{ts,tsx}-- test files
The apps/web config also relaxes limits for src/components/ui/** (shadcn/ui components are generated, not hand-written).
Import Sorting
The simple-import-sort/imports rule enforces a fixed group order:
- Side-effect imports (
import "./styles.css") - React and Next.js (
react,react-dom,next/*) - External npm packages (
@tanstack/*,zod, etc.) - Internal monorepo packages (
@repo/db,@repo/api, etc.) - Relative imports (
./,../) - CSS imports (
*.css)
Both import and export sorting are set to error and are auto-fixable.
Code Smell Detection (SonarJS)
Rather than enabling the full sonarjs/recommended preset, the config cherry-picks specific rules:
| Rule | What it catches |
|---|---|
cognitive-complexity | Functions that are hard to understand (redundant with the complexity limit, but measures differently) |
no-identical-functions | Copy-pasted function bodies |
no-collapsible-if | Nested if statements that can be merged |
no-duplicated-branches | if/else branches with identical code |
no-all-duplicated-branches | All branches of a conditional are identical |
no-identical-expressions | a === a or x && x patterns |
prefer-single-boolean-return | if (x) return true; return false; instead of return x; |
no-redundant-jump | continue, return, or break that serve no purpose |
no-nested-conditional | Ternary inside ternary or deeply nested conditionals |
TODO Expiration
The unicorn/expiring-todo-comments rule requires every TODO, FIXME, or HACK comment to include either a date or ticket reference:
// TODO [2026-05-01]: Migrate to streaming API
// FIXME [TRO-45]: Race condition in cache invalidation
// HACK [TRO-99]: Workaround for upstream bug in typesense client
Comments without a date or ticket reference cause a lint error. On pull requests, date-based expiry is ignored (the ignoreDatesOnPullRequests option) to avoid blocking PRs that happen to land on an expiry date.
Documentation Requirements
Two rules enforce documentation standards:
jsdoc/require-jsdoc (error) -- requires JSDoc on:
- Exported function declarations (3+ lines)
- Exported class declarations (3+ lines)
- Exported type aliases and interfaces
This rule uses publicOnly: true, so internal (non-exported) code does not need JSDoc.
tsdoc/syntax (warn) -- validates that doc comments use valid TSDoc syntax. Set to warn rather than error to allow gradual adoption of advanced TSDoc tags.
General Rules
| Rule | Setting | Why |
|---|---|---|
no-console | Error | All logging goes through @repo/logger; console.log is banned |
prefer-const | Error | Immutable by default |
no-var | Error | let and const only |
eqeqeq | Error (always) | No loose equality (==); only strict (===) |
SDK Boundary Enforcement
The restrictedImports() function in packages/config-eslint/restrictions.js generates no-restricted-imports rules that enforce the package boundary architecture. Each external SDK may only be imported from its designated wrapper package:
| SDK | Allowed in | Wrapper purpose |
|---|---|---|
@anthropic-ai/sdk | @repo/ai | AI client, usage tracking, model selection |
@google/genai | @repo/ai | Google AI models (Gemini) |
better-auth | @repo/auth, @repo/web | Authentication server/client |
better-auth/client | @repo/auth, @repo/web | Auth client utilities |
better-auth/react | @repo/web | React hooks for auth state |
drizzle-orm/pg-core | @repo/db | Schema definitions |
drizzle-orm/node-postgres | @repo/db | Database driver |
drizzle-orm/node-postgres/migrator | @repo/db | Migration runner |
@upstash/redis | @repo/cache | Redis client |
pino | @repo/logger | Structured logging |
typesense | @repo/search | Search engine client |
How It Works
Each package's eslint.config.js calls restrictedImports("@repo/package-name"). The function returns a deny list for every SDK that the calling package is not in the allowed list for:
// packages/api/eslint.config.js
import library from "@repo/eslint-config/library";
import { restrictedImports } from "@repo/eslint-config/restrictions";
export default [
...library,
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { paths: restrictedImports("@repo/api") }],
},
},
];
Because @repo/api is not in the allowed list for any SDK, it gets deny rules for all of them. If a developer tries to import { Anthropic } from "@anthropic-ai/sdk" inside @repo/api, ESLint reports:
Import @anthropic-ai/sdk only through @repo/ai. Direct usage is banned outside the wrapper package.
Router-Specific Restrictions
The @repo/api config adds extra restrictions for router files (src/routers/**/*.ts). These prevent routers from bypassing the middleware context:
| Banned import | Why | Use instead |
|---|---|---|
db from @repo/db | Bare db skips RLS tenant scoping | ctx.db (RLS-scoped) |
Anything from @repo/logger | Routers should use the request-scoped logger | ctx.logger |
createAIHelper from @repo/ai | Routers should use the pre-bound helper | ctx.ai |
Web App Restrictions
The apps/web config bans barrel imports from features:
patterns: [
{
group: ["~/features/*/index", "~/features/*/index.*"],
message: "Import directly from the file, not the feature barrel. See TRO-93.",
},
];
This prevents circular dependency issues that arise from feature barrel re-exports.
Per-Package Config Pattern
Every package follows the same pattern in its eslint.config.js:
- Spread the appropriate preset (
libraryornext) - Add
no-restricted-importsviarestrictedImports("@repo/package-name") - Optionally add package-specific rule overrides
// Minimal example: packages/db/eslint.config.js
import library from "@repo/eslint-config/library";
import { restrictedImports } from "@repo/eslint-config/restrictions";
export default [
...library,
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { paths: restrictedImports("@repo/db") }],
},
},
];
Adding a New SDK Boundary
When introducing a new external service SDK:
- Create or identify the wrapper package (e.g.,
@repo/emailfor Resend) - Add the SDK to the
SDK_BOUNDARIESarray inpackages/config-eslint/restrictions.js - Run
pnpm lintto verify zero violations across the monorepo - Update the layer hierarchy in
.dependency-cruiserrc.mjsif needed (see Delivery -- Architecture Enforcement)