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.tsThe 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.
// 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 infindProductandfindAdminCampaigns. - A computed or config value uses
get*, as ingetDashboardViewModelandgetDefaultCurrency. - A call to an outside service uses
fetch*, as infetchShopifyProducts. - A write uses
create*,update*ordelete*. Don't use add, edit, remove, insert or set. - A boolean uses
is*,has*orcan*. - A conversion uses
to*orfrom*, as intoCentsandfromDecimal.
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:
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
CampaignVisibilityImports 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.
"paths": {
"~/*": ["./*"],
"~/.source/*": ["./.source/*"],
"content-collections": ["./.content-collections/generated"]
}import { db } from "~/services/db" // Path alias
import { cx } from "~/utils/cva" // Class merging (not cn)
import { Icon } from "~/components/common/icon" // Sprite iconsThe checks enforce three import rules:
- Use
cxfrom~/utils/cva, notcn. The repository has nolib/cnmodule, and theprefer-cxrule flags the alternatives. - Use icons from the sprite. Writing
import { Store } from "lucide-react"inapp/orcomponents/fails theicon-importsrule. Write<Icon name="lucide/store" />instead. Onlycomponents/common/icon.tsxandscripts/build-icons.tsare allowed to import icons directly. - Don't re-export through barrels. The
no-export-starrule rejectsexport * frominapp,components,server,services,libandutils. Named exports let you grep where something comes from.
A separate set of rules controls which folders may import from which:
no-app-importsno-admin-in-storefrontno-storefront-in-adminno-server-in-commonno-server-in-libno-upward-importsno-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 operationsUnder app/, a route folder only contains the files Next.js expects:
page.tsx
layout.tsx
loading.tsx
error.tsx
route handlersDon'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
lockno-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
hooksIt 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:
/**
* 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/.
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-importstable-columnsprefer-cxprefer-admin-toastno-raw-toast-adminprefer-admin-section-cardhand-rolled-spinnerhand-rolled-empty-statehand-rolled-list-rowsweb-hand-rolled-cardgrid-card-filesstacked-label-formno-primoui-formattersno-inline-day-windowsno-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-activityactivity-coveragecritical-no-transactionno-broad-revalidatewrite-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-shelladmin-page-auththin-route-files
Theme and token boundaries
web-tone-surfaceweb-hardcoded-colorweb-themed-borderno-double-wrapped-colorno-destructive-variantno-important
Money, dates and units
no-money-on-major-fieldcurrency-unit-mismatchno-localeless-formatno-unsuppressed-relative-timeno-ui-business-date-mathno-cap-as-all
One authority for each job
canonical-authoritystorefront-composition-authoritycomposed-route-uses-resolvercommerce-link-authoritypicker-url-authorityoperator-alert-routerworkflow-agnosticresolver-no-reactno-raw-provider-url
The rest
The remaining rules are small and specific:
no-window-location(use thenext/navigationrouter instead; tag a line withdiscipline-okto skip it)no-window-promptno-set-timeoutno-ssr-dynamicno-tuple-thenno-success-wrapperno-as-brandno-raw-derived-stateno-manual-pendingbusy-prop-namingforms-need-resolverdirective-placementnamed-props-typeno-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.
Related
Harness
Learn about the 25 CI checks that run on your fork, and the 8-guide rule set behind them.
Guides
Learn what the eight guides in .claude/guides/ contain, how they hang off .claude/CLAUDE.md, and which one to read before a page, a model, a module or a refactor.
Linting
Learn how Biome formats and lints the codebase, what bun run lint and bun run verify actually run, and which lefthook hooks fire on commit and push.