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.tsThe 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.
// 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*ordelete*— not add, edit, remove, insert or set - A boolean is
is*,has*orcan* - A conversion is
to*orfrom*—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:
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
CampaignVisibilityImports and the ~/ alias
tsconfig.json maps ~/* to the repository root, so every shared module is imported through the alias rather than 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 iconsThree import rules are enforced rather than suggested:
cxfrom~/utils/cva, notcn. There is nolib/cnmodule in the repository; theprefer-cxrule flags the alternatives.- Icons come from the sprite.
import { Store } from "lucide-react"inapp/orcomponents/fails theicon-importsrule — the form is<Icon name="lucide/store" />. Onlycomponents/common/icon.tsxandscripts/build-icons.tsare allowlisted. - No barrel re-exports. The
no-export-starrule rejectsexport * fromacrossapp,components,server,services,libandutils; named exports keep provenance greppable.
Which direction an import may travel is a separate set of rules of its own:
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 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 operationsUnder app/, a route folder holds Next.js conventions only:
page.tsx
layout.tsx
loading.tsx
error.tsx
route handlersThe _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
lockno-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
hooksTests, __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:
/**
* 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/.
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-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 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-activityactivity-coveragecritical-no-transactionno-broad-revalidatewrite-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-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
Idioms that keep one authority
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; a line taggeddiscipline-okopts outno-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
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.
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.