Litestore · Developer guide

Style

Learn the naming, import and file-layout conventions the codebase follows, and the two scripts — check-no-as-any.ts and check-discipline.ts — that fail on a new violation.

The conventions on this page are written down in .claude/guides/code-style.md and .claude/CLAUDE.md, and most of them are machine-checked.

Two scripts do the checking:

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

The first fails on a new as any. The second carries 69 named structural rules, each with its own baseline file.

Both are ratchets: today's violations are recorded, new ones fail. That is what lets a convention be introduced into an existing codebase without a rewrite first.

Functions and naming

Pure functions are function declarations. Server actions are arrow functions built through 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 })
  })

One verb per operation kind. The guide names the split explicitly because the codebase drifted to a roughly even get/find coin-flip for reads.

  • A database read returning entities is find*findProduct, findAdminCampaigns
  • A computed or config value is get*getDashboardViewModel, getDefaultCurrency
  • External I/O is fetch*fetchShopifyProducts
  • A write is create*, update* or delete* — not add, edit, remove, insert or set
  • A boolean is is*, has* or can*
  • A conversion is to* or from*toCents, fromDecimal

A generic name needs a domain qualifier or a shared home.

getProvider, getPlatformConfig and isFeatureEnabled defined once per module shadow each other and cannot be grepped apart, so lint:dupes (scripts/check-duplicate-utils.ts) fails on a new 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.

Component props types are named after the component. A bare type Props = in components/ is rejected by the named-props-type rule; type CouponDetailProps is the form.

Types, enums and constants

String enums are const objects with an inferred type, not 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]

Types are co-located with the implementation they describe. A zod schema and its z.infer type live in the same file, and there is no types/product.ts sitting beside schema/product.ts.

Before defining a type, the guide tells you to grep lib/, components/ and server/ for an existing definition and import it.

Prisma enums are held to a narrower rule. A value derivable from timestamps, relationships, events or policy must not be a writable enum — it is derived through one canonical resolver such as lib/reviews/review-lifecycle.ts or customerCrud.deriveLifecycleStatus.

Enums that describe stable domain categories stay:

DiscountType
PaymentTransactionType
FinancialStatus
UserRole
InventoryPolicy
CampaignVisibility

Imports and the ~/ alias

tsconfig.json maps ~/* to the repository root, so every shared module is imported through the alias rather than 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

Three import rules are enforced rather than suggested:

  • cx from ~/utils/cva, not cn. There is no lib/cn module in the repository; the prefer-cx rule flags the alternatives.
  • Icons come from the sprite. import { Store } from "lucide-react" in app/ or components/ fails the icon-imports rule — the form is <Icon name="lucide/store" />. Only components/common/icon.tsx and scripts/build-icons.ts are allowlisted.
  • No barrel re-exports. The no-export-star rule rejects export * from across app, components, server, services, lib and utils; named exports keep provenance greppable.

Which direction an import may travel is a separate set of rules of its own:

  • 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 everything about 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 holds Next.js conventions only:

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

The _components/, _lib/, _hooks/ and _utils/ private-folder convention is rejected. Route UI goes in components/admin/<section>/ or components/web/<section>/, and route helpers in lib/<module>/.

The thin-route-files rule enforces the boundary by flagging a .map( whose callback contains JSX inside a route page.tsx.

Comments follow the same subtraction rule.

no-narration-comments flags a verb-first comment restating the next line, unless it also carries a constraint. These tokens exempt it:

because
must
race
stale
idempoten
atomic
lock

no-ceremonial-jsdoc flags a one-line /** Get the active theme */ block above a symbol of the same name, keeping JSDoc that carries @param detail or a SECURITY/CASCADE/invariant note.

No as any

scripts/check-no-as-any.ts scans for as any and as unknown as across:

server
services
lib
components
app
contracts
utils
hooks

Tests, __tests__ and .d.ts files are skipped, and matches that only appear inside a comment are ignored.

The baseline key is deliberately not line-based:

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.
 */

Pre-existing casts sit in scripts/no-as-any-baseline.txt; anything not in that file exits 1.

The script's own header records why it was rewritten. The previous version scanned nine hardcoded paths and reported zero, while roughly ninety casts lived elsewhere.

bun run scripts/check-no-as-any.ts --update-baseline rewrites the baseline and always exits 0. It is the deliberate act that makes accepting a new cast visible in a diff — not a way to clear a failure.

The 69 discipline rules

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

Each rule declares:

  • Its scan paths
  • File extensions
  • An allowlist of exempt paths
  • A per-line or per-AST detector
  • A fix hint

Each also 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 group into six kinds of enforcement.

One canonical implementation

These rules exist because the same UI or utility was being hand-rolled 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 raw db.X.create/update/delete/upsert in an actions.ts.

The rest cover the audit trail, transaction and invalidation side of the same write:

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

Permissions and route shape

unwrapped-server-actions fails a createServerAction() under server/ that is not chained off a permission builder. server/web/ is allowlisted, because storefront actions are session-scoped and public.

Three more cover 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

Idioms that keep one authority

  • 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; a line tagged discipline-ok opts out
  • 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

Renames delete the old name in the same change. The guide lists what is not allowed:

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

There are zero export * from statements in the codebase, and _unusedVar renames are rejected in favour of deleting the code.

The test for a rename is not whether the build passes. It is:

Does the old name still resolve anywhere? If it does, the rename is not finished.

On this page