Litestore · Developer guide
Codebase

Style

This page shows you the naming, import and file-layout conventions to follow so your code passes the style checks.

Litestore's code follows a set of conventions for naming, imports and file layout. This page explains them so your changes look like the rest of the codebase and pass the checks.

The conventions are written down in .claude/guides/code-style.md and .claude/CLAUDE.md, and scripts check most of them automatically. Two scripts do the checking:

scripts/check-no-as-any.ts
scripts/check-discipline.ts

The first one fails when you add a new as any. The second runs 69 named structural rules, and each rule has its own baseline file.

Both scripts work as ratchets. They record today's violations and fail on new ones. That's how a new convention can apply to an existing codebase without rewriting everything first.

Functions and naming

Write pure functions as function declarations. Write server actions as arrow functions built with the module's permission wrapper.

.claude/guides/code-style.md
// Pure functions - use function declarations
export function calculateDiscount(price: number, percent: number): number {
  return price * (1 - percent / 100)
}

// Server actions - use arrow functions with the module permission wrapper
export const createOrder = orderPermission("create")
  .createServerAction()
  .input(orderSchema)
  .handler(async ({ input }) => {
    return db.order.create({ data: input })
  })

Each kind of operation gets one verb. The guide spells this out because the codebase had drifted until reads were named get or find about equally often.

  • A database read that returns entities uses find*, as in findProduct and findAdminCampaigns.
  • A computed or config value uses get*, as in getDashboardViewModel and getDefaultCurrency.
  • A call to an outside service uses fetch*, as in fetchShopifyProducts.
  • A write uses create*, update* or delete*. Don't use add, edit, remove, insert or set.
  • A boolean uses is*, has* or can*.
  • A conversion uses to* or from*, as in toCents and fromDecimal.

A generic name needs either a domain in front of it or a single shared home.

If every module defines its own getProvider, getPlatformConfig or isFeatureEnabled, the copies shadow each other and you can't tell them apart with grep. lint:dupes (scripts/check-duplicate-utils.ts) fails when you add a new name collision across lib/ and utils/.

Core modules also avoid vendor names. The guide's example replaces an IMPORT_SOURCES map keyed by shopify/woocommerce/tiktok with a generic SalesChannel const.

Name a component's props type after the component. The named-props-type rule rejects a bare type Props = in components/. Write type CouponDetailProps instead.

Types, enums and constants

Write string enums as const objects with an inferred type, not as a TypeScript enum:

.claude/guides/code-style.md
export const ProductStatus = {
  Draft: "Draft",
  Published: "Published",
  Scheduled: "Scheduled",
} as const
export type ProductStatus = (typeof ProductStatus)[keyof typeof ProductStatus]

Put a type in the same file as the code it describes. A zod schema and its z.infer type go in the same file. There is no types/product.ts next to schema/product.ts.

Before you define a type, the guide asks you to grep lib/, components/ and server/ for an existing one and import it.

Prisma enums follow a stricter rule. If a value can be worked out from timestamps, relationships, events or policy, it must not be an enum you can write to. Instead, one canonical resolver works it out, such as lib/reviews/review-lifecycle.ts or customerCrud.deriveLifecycleStatus.

Enums that describe stable categories in the domain stay as enums:

DiscountType
PaymentTransactionType
FinancialStatus
UserRole
InventoryPolicy
CampaignVisibility

Imports and the ~/ alias

tsconfig.json maps ~/* to the root of the repository. You import every shared module through this alias instead of a relative path.

tsconfig.json
"paths": {
  "~/*": ["./*"],
  "~/.source/*": ["./.source/*"],
  "content-collections": ["./.content-collections/generated"]
}
.claude/CLAUDE.md
import { db } from "~/services/db"              // Path alias
import { cx } from "~/utils/cva"                // Class merging (not cn)
import { Icon } from "~/components/common/icon" // Sprite icons

The checks enforce three import rules:

  • Use cx from ~/utils/cva, not cn. The repository has no lib/cn module, and the prefer-cx rule flags the alternatives.
  • Use icons from the sprite. Writing import { Store } from "lucide-react" in app/ or components/ fails the icon-imports rule. Write <Icon name="lucide/store" /> instead. Only components/common/icon.tsx and scripts/build-icons.ts are allowed to import icons directly.
  • Don't re-export through barrels. The no-export-star rule rejects export * from in app, components, server, services, lib and utils. Named exports let you grep where something comes from.

A separate set of rules controls which folders may import from which:

  • no-app-imports
  • no-admin-in-storefront
  • no-storefront-in-admin
  • no-server-in-common
  • no-server-in-lib
  • no-upward-imports
  • no-admin-wrapper-in-services

File layout

A server module keeps all the code for one domain in one folder:

server/admin/products/
├── actions.ts         # Server actions (entry points)
├── crud.ts            # BaseCRUD extension
├── queries.ts         # Read-only queries
├── schema.ts          # Zod schemas, nuqs parsers
├── payloads.ts        # Prisma select/include types
├── types.ts           # TypeScript types
└── workflows.ts       # Complex multi-step operations

Under app/, a route folder only contains the files Next.js expects:

page.tsx
layout.tsx
loading.tsx
error.tsx
route handlers

Don't use private folders such as _components/, _lib/, _hooks/ or _utils/. Put route UI in components/admin/<section>/ or components/web/<section>/, and route helpers in lib/<module>/.

The thin-route-files rule enforces this. It flags a .map( inside a route's page.tsx when the callback contains JSX.

Comments follow the same idea of leaving out what isn't needed.

no-narration-comments flags a comment that starts with a verb and just repeats what the next line does. A comment is allowed if it also states a constraint, which the rule detects by these words:

because
must
race
stale
idempoten
atomic
lock

no-ceremonial-jsdoc flags a one-line block such as /** Get the active theme */ above a symbol with the same name. JSDoc that adds @param details or a SECURITY, CASCADE or invariant note is kept.

No as any

scripts/check-no-as-any.ts looks for as any and as unknown as in these folders:

server
services
lib
components
app
contracts
utils
hooks

It skips tests, __tests__ and .d.ts files, and it ignores matches that only appear inside a comment.

The baseline doesn't use line numbers, on purpose:

scripts/check-no-as-any.ts
/**
 * A churn-resistant key: `path::normalized-code`. Line numbers are dropped (they
 * drift as files change); the trimmed source line is the identity, so moving a
 * cast within a file doesn't read as a new violation while a genuinely new cast
 * does.
 */

Casts that already existed are listed in scripts/no-as-any-baseline.txt. Any cast that isn't in that file makes the script exit with 1.

The header of the script records why it was rewritten. The old version scanned nine hardcoded paths and reported zero casts, while about ninety casts were in other folders.

Updating the baseline accepts new casts

bun run scripts/check-no-as-any.ts --update-baseline rewrites the baseline and always exits 0. Run it only when you mean to accept a new cast, so the change shows up in your diff. Don't use it to clear a failure.

The 69 discipline rules

scripts/check-discipline.ts runs as bun run lint:discipline.

Each rule declares where it scans, which file extensions it reads and which paths are exempt. It also has a detector that works line by line or on the AST, and a hint that tells you how to fix a violation. Each rule writes to its own file under scripts/discipline-baselines/.

scripts/check-discipline.ts
type Rule = {
  name: string
  description: string
  baselinePath: string
  scanPaths: string[]
  extensions: string[]
  allowlist: RegExp[]
  fileFilter?: RegExp
  scanFile: (src: string, path: string) => number[]
  fixHint: string
}

The 69 rules fall into six kinds, plus a group of smaller ones.

One canonical implementation

These rules exist because people kept building the same UI or utility a second time:

  • icon-imports
  • table-columns
  • prefer-cx
  • prefer-admin-toast
  • no-raw-toast-admin
  • prefer-admin-section-card
  • hand-rolled-spinner
  • hand-rolled-empty-state
  • hand-rolled-list-rows
  • web-hand-rolled-card
  • grid-card-files
  • stacked-label-form
  • no-primoui-formatters
  • no-inline-day-windows
  • no-duplicate-media-association-flow

Writes go through the CRUD layer

action-mutations rejects a raw db.X.create/update/delete/upsert in an actions.ts file.

The other rules in this group make sure a write also logs activity, uses a transaction where needed and invalidates the cache:

  • raw-mutation-activity
  • activity-coverage
  • critical-no-transaction
  • no-broad-revalidate
  • write-schema-columns-coverage

Permissions and route shape

unwrapped-server-actions fails when a createServerAction() under server/ isn't built on a permission builder. server/web/ is exempt, because storefront actions are public and scoped to the shopper's session.

Three more rules check the route files themselves:

  • admin-page-shell
  • admin-page-auth
  • thin-route-files

Theme and token boundaries

  • web-tone-surface
  • web-hardcoded-color
  • web-themed-border
  • no-double-wrapped-color
  • no-destructive-variant
  • no-important

Money, dates and units

  • no-money-on-major-field
  • currency-unit-mismatch
  • no-localeless-format
  • no-unsuppressed-relative-time
  • no-ui-business-date-math
  • no-cap-as-all

One authority for each job

  • canonical-authority
  • storefront-composition-authority
  • composed-route-uses-resolver
  • commerce-link-authority
  • picker-url-authority
  • operator-alert-router
  • workflow-agnostic
  • resolver-no-react
  • no-raw-provider-url

The rest

The remaining rules are small and specific:

  • no-window-location (use the next/navigation router instead; tag a line with discipline-ok to skip it)
  • no-window-prompt
  • no-set-timeout
  • no-ssr-dynamic
  • no-tuple-then
  • no-success-wrapper
  • no-as-brand
  • no-raw-derived-state
  • no-manual-pending
  • busy-prop-naming
  • forms-need-resolver
  • directive-placement
  • named-props-type
  • no-export-star
  • the seven import-direction rules

Checking the checker

scripts/check-discipline-hygiene.ts runs as bun run lint:hygiene.

It fails on a baseline entry whose file no longer exists, and on an allowlist regex that matches nothing.

It has no baseline of its own.

No backwards compatibility

When you rename something, delete the old name in the same change. The guide lists what you must not leave behind:

  • Type aliases (export type OldName = NewName)
  • Function aliases (export const oldFn = newFn)
  • Re-exports for renamed modules
  • Deprecated parameter support
  • Legacy API endpoints

The codebase has no export * from statements. Renaming an unused variable to _unusedVar is rejected too. Delete the code instead.

A passing build doesn't prove a rename is done. Ask this:

Does the old name still resolve anywhere? If it does, the rename isn't finished.

On this page