Skip to content

Coding Standards ​

What reviewers enforce. These are the day-to-day rules; the architecture pages explain why.

Definition of Done ​

A change is done when all of these hold:

  • Implemented to standards.
  • Unit + smoke tests green (+ integration/E2E where relevant).
  • Security & lint gates pass.
  • Performance budget respected.
  • Docs / ADR updated ("if it isn't documented, it isn't done").
  • Observability added for new surfaces.
  • Migrations expand-contract and promoted to both Supabase projects.
  • Tracker (QRS-###) updated.
  • Verified end-to-end in-app, not just via tests.

The hard rules [ENFORCED] ​

RuleMeaning
No supabase.from() in src/Reads → RPC in hooks; writes → EF in services. Data Access.
No inline EF namesEF names come from a per-feature EDGE_FN map (UPPER_SNAKE_CASE).
One Supabase clientsrc/lib/supabaseClient.ts; no second createClient; no hardcoded prod fallback URL.
Tier boundariespublic ⇏ user,admin; user ⇏ admin. Features import UI from their tier's components/ui/.
Zero hard-coded colorsIn class strings (chart stroke/fill excepted). Theme via cn() + isLight/tokens.
No per-page theme branchingLight/dark driven centrally.
RLS on every user-facing tablePlus the EF as primary write gate.

Naming conventions [ENFORCED] ​

ThingConventionExample
ComponentPascalCaseReminderRow.tsx
Hookuse + camelCaseuseReminders.ts
Data-layer contract{Domain}Service in packages/data/src/{domain}/service.ts (+ service.stub.ts)remindersService
Tablesnake_case, domain-specificreminder_occurrences, setu_cards
RPCone of five families — see belowget_industries, resolve_features
Edge Functionkebab domain-action (Type A) / domain-noun-verb (Type B)manage-reminder
EDGE_FN keyUPPER_SNAKE_CASE, in a per-domain *_EDGE_FN mapREMINDERS_EDGE_FN.MANAGE_REMINDER
Migration14-digit YYYYMMDDHHMMSS_name.sql20260808100000_v2_identity_and_tenancy.sql
ADRNNNN-kebab-title.md0020-platform-schema-first-principles-redesign.md
Tracker idQRS-###QRS-042

Examples corrected 2026-08-08

This table previously illustrated itself with MenuItemCard.jsx, useDigitalMenu.js, digital_menus and public_page_ops_cache_refresh — a .jsx component from the retired SPA, and two table/function families that were dropped from Dev on 2026-08-08. A naming table is copied verbatim more often than almost any other page, so a stale example here propagates.

SQL functions — FIVE families, not one ​

The prefix tells a reader what kind of work happens. Anything outside these five is a naming violation, not a sixth family.

FamilyMeansExamples
get_*Retrieve stored rows, possibly projectedget_industries · get_public_setu_card · get_my_features
resolve_*Derive an answer by computation over several sourcesresolve_features · resolve_workspace_plan
is_*Predicate returning a bare booleanis_slug_reserved
my_*RLS helper scoped to auth.uid(), called from inside a policymy_workspace_ids · my_oversight_workspace_ids
{table}_{action}Trigger function, named for what it guardsworkspaces_maintain_path · setu_cards_slug_write_once

resolve_* and my_* earn their place rather than merely differing: resolve_* signals the result is computed and not stored, so nobody goes looking for a table behind it; and my_* reads correctly at the call site inside a policy — workspace_id = any (my_workspace_ids()) — where get_my_* would add noise to the most performance-sensitive expressions in the schema.

Table names must be domain-specific, never generic ​

The test is "will a second thing plausibly want this name?" QRSETU expects to grow vCards, invitation cards and event cards, so cards was ambiguous on arrival and became setu_cards. A dairy has meal plans and a gym has membership plans, so plans became platform_plans. And primitives collided with apps/mobile/src/ui — the systemic RN primitive set — so it became process_primitives: the same word meaning two unrelated things inside one repo.

Column names stay short (archetype_key, plan_key): the table name carries the disambiguation, and a business_archetype_key column would be verbose without adding clarity.

Consistency ​

  • New code reads like surrounding code — match idiom, structure, naming.
  • No speculative abstractions; no "temporary" hacks on release branches.
  • No regressions. Surface bugs/debt/risks and confirm before non-trivial or cross-cutting changes.
  • Validate each step (tests/build) before the next.

TypeScript [ENFORCED — TS-first] ​

  • .ts/.tsx for logic; keep UI glue idiomatic.
  • Shared, platform-agnostic core (types, Zod schemas, contracts, pure logic) — no react-dom/DOM imports — so web and future native both consume it.
  • Convert .js/.jsx opportunistically during retrofit.

Lint & hooks ​

  • Flat config eslint.config.mjs — keeps no-undef / import/no-self-import as errors; some noisy rules disabled deliberately (read the inline comments before re-enabling).
  • Husky pre-commit runs type-check && lint (project-wide). Planned: lint-staged + affected tests.
  • Planned guardrail rules: no from() in src/, no inline EF names, no second createClient, tier import boundaries, no hard-coded colors, no per-page theme branching, + eslint-plugin-sonarjs / eslint-plugin-security.

Principal-Architect stance ​

Evaluate every change as a Principal Solution Architect: surface better alternatives, call out debt/risk, and log every confirmed finding to the tracker (QRS-###) — log-after-confirmation, never fix-and-forget.

TypeScript (operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "TypeScript" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

TypeScript [ENFORCED — TS-first] ​

TS-first with a shared, platform-agnostic core (types, Zod schemas, service/data-access contracts, pure logic — no react-dom/DOM imports) so web and the Expo app both consume it. .tsx/.ts for logic; keep UI glue idiomatic. TypeScript is non-negotiable for all new code; a phased JS→TS migration roadmap covers the remaining core modules (opportunistic + planned), with Digital Menu on its own R2 migration (excluded from R1 and re-homed onto the E-commerce archetype — ADR-0009). Archetype definitions, Zod field schemas, and entitlement/vertical resolvers are TS in the shared core from day one. (This deliberately diverges from NEFOXX's "no TypeScript" rule — invert only that; keep every other NEFOXX guardrail.)

Feature-scoped naming (QRS-436, operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "FEATURE-SCOPED NAMING" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

FEATURE-SCOPED NAMING [ENFORCED — non-negotiable, gated, owner instruction 2026-08-09, QRS-436] ​

Name the FEATURE, never the shape. The rule above covered tables; this generalises it to every artifact — files, directories, exported identifiers, schemas, Edge Functions, npm scripts, hooks, types, React components. A name may not describe only what a thing is (Template, Card, Block, Manifest) when it must also say what it belongs to (SetuCardTemplate).

Why this is a top-level rule and not a style preference. Generic naming has had to be swept out of this repo four times, and every single one was found by a human reading a filename — never by a gate, and never at the moment the name was introduced:

WordThe collision
cardsvCards, event cards, greeting cards and visiting cards are all planned → setu_cards
plansa dairy has meal plans, a gym has membership plans → platform_plans
primitivesTHREE live meanings at once: ADR-0020 process primitives, the per-app src/ui component primitives, and @qrsetu/tokens' raw colour ramps → process_primitives / colorPrimitives
templatesTWO live meanings at once: supabase/templates/ held Supabase Auth's 13 email templates while card-template.ts held Setu Card manifests — and npm run check:templates validated only the second

By the time a reader notices, the name has spread: templates had reached 254 live code occurrences and 358 portal ones before anyone said the word out loud. Renaming at the moment of introduction is one keystroke; renaming later is a cross-cutting sweep. That asymmetry is the whole argument.

The decidable line — this is what the gate enforces, and why it does not just ban a word list:

A name that CROSSES a module boundary must carry its feature scope. A name already scoped by the directory it lives in need not repeat it.

So features/setu-card/blocks/HeaderBlock.tsx is correct — the path travels with the reader, and SetuCardHeaderBlock inside setu-card/blocks/ would be noise. But import type { CardBlock } from '@qrsetu/schemas' is wrong: at that call site there is no path at all, and "Card" could mean any of five planned card products.

What must be feature-scoped: exported packages/* identifiers · directory names · SQL tables, functions, views · Edge Function names · npm scripts · top-level modules. What need not be: local variables, parameters, non-exported helpers, and intra-feature file and component names.

Two deliberate exemptions, because an over-broad rule is one that gets bypassed:

  • SQL COLUMN names stay short — the table carries the disambiguation (see the rule above: archetype_key, never business_archetype_key). setu_cards.template_key is correct, and flagging it would put the gate in conflict with this file's own documented convention.
  • Genuinely cross-feature concepts keep a neutral name where the neutrality is the point — a design token (Palette, SemanticSet) is consumed by every card type, so scoping it to one would be wrong. Add such a prefix to QUALIFIED_PREFIXES in tools/check-naming.js with a written reason, never silently.

Forward-looking, because the expansion is already planned. Everything card-shaped in the repo today is the Setu Card, and there is no second card product yet. Name as if there were: SetuCardTemplate / EventCardTemplate / VCardTemplate, setu_card_templates / event_card_templates. Do not build a shared abstraction across card types that do not exist — an event card's blocks (date, venue, RSVP) share almost nothing with a Setu Card's, so a generic CardTemplate with a cardType discriminator would be the branch-on-type sprawl ADR-0021 D4 bans elsewhere. Each card product gets its own contract when it arrives.

Enforcement — four layers, because a standard with no gate decays (this file's own repeated lesson):

  • npm run check:naming (tools/check-naming.js) — N1 package exports · N2 directories · N3 npm scripts · N4 SQL objects. Incident-driven root list; each entry names its collision. Mutation-tested in both directions (tools/check-naming.test.mjs, 10 cases) per QRS-013.
  • Claude Code hook (tools/hooks/on-naming-surface-edit.mjs) — fires the moment a boundary-crossing file is edited, so the feedback lands while the reasoning is still in context rather than at commit. Unit-tested via npm run test:hooks.
  • pre-commit and pre-push, then CI (ci.yml) as the authority.
  • npm run check:docs carries a retired-vocabulary group so the generic word cannot return to the portal.

⚠ The gate earned its place on first run: it immediately found export ArchetypeKey in packages/data (now BusinessArchetypeKey) and export const primitives in @qrsetu/tokens — the third simultaneous meaning of that word, in a repo whose own CLAUDE.md already documented the other two.

Lint and format (operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "Lint & format" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

Lint & format [ENFORCED — mandatory, whole-tree, warnings = errors] ​

Every line committed is linted and format-checked — no opt-in, no silent skips. The monorepo lints as one central eslint . at the repo root (root npm run lint = eslint . --max-warnings=0), NOT a per-workspace fan-out — so coverage is the default and a new file/workspace can never escape the gate. (This replaced a lint --workspaces --if-present that matched zero workspaces and passed CI as a green no-op — a false gate; see the dev-tracker.) Rules live in the shared @qrsetu/eslint-config package as composable layers (base = ESLint + typescript-eslint + import recommended · reactWeb/reactNative idioms · node · guardrails · prettierCompat last); the root eslint.config.mjs only scopes each layer to its path. Deno edge functions (supabase/**) are excluded from ESLint. ⚠ deno lint is wired NOWHERE — zero hits across .github/workflows/, .husky/ and package.json — so supabase/** is currently linted by nothing (QRS-327, still open, and this sentence has asserted the opposite for months).

  • Commands (monorepo root): npm run lint (gate) · npm run lint:fix · npm run format (Prettier write) · npm run format:check (gate). Formatting is Prettier (.prettierrc.json), separate from lint; both are required CI checks (ci.yml) alongside type-check + test.
  • Pre-commit: husky (v9) runs seven gates — check:readmes, check:parity, check:naming, check:docs, check:portal-nav, check:screens, check:arch-proposal — then lint-staged (⚠ this named only check:readmes until 2026-08-28) (ESLint --fix --max-warnings=0 + Prettier on staged files only — fast); the full-tree gate + project-wide type-check run in CI.
  • Active guardrails (@qrsetu/eslint-config/guardrails.js): no hard-coded colors in apps/** (use tokens; chart/ illustration fills are the sanctioned exception — disable inline with a reason), tier import boundaries (mobile user ⇎ admin), package purity (packages/tooling never import app @/ code). Deferred (added WITH the code they guard, not speculatively): no from() in app code (with packages/data), no inline EF names (with the service layer), the full schemas→domain→data DAG. ⚠ eslint-plugin-sonarjs and eslint-plugin-security are INSTALLED AND WIRED — both are in package.json and tooling/eslint-config exports a sonar layer. This said "still planned" until 2026-08-28, while the same file described the ~217-rule sonarjs gate 35 lines later.
  • Legacy note: the retired Vite SPA under legacy/ keeps its own eslint.config.mjs and is not linted by the root gate (ignored) — it is reference only.

Path aliases and TS config (operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "Path aliases & TS config" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

Path aliases & TS config ​

Each app owns its own @/*. apps/mobile: @/* → apps/mobile/src/* and @/assets/* → apps/mobile/assets/* (apps/mobile/tsconfig.json, extending expo/tsconfig.base; strict). It also sets allowImportingTsExtensions because @qrsetu/domain's internal relative imports carry explicit .ts extensions so node --test resolves them with no bundler and no added dependency (QRS-212) — tsc follows those while checking the app, so the flag has to be set on both sides. Shared base configs live in tooling/typescript-config. Cross-package imports always go through the @qrsetu/* workspace name, never a relative path out of a package.

Every TS workspace type-checks INDEPENDENTLY [QRS-015]. Root type-check used to fan out --workspaces --if-present while only apps/mobile and packages/domain defined the script, so the other seven packages were checked only transitively — through whatever the app happened to import. All ten now own a tsconfig.json + type-check (apps/web arrived after this sentence was written). (tooling/* is JS/JSON only and correctly has none.)

packages/* deliberately have NO DOM and NO Node types. tooling/typescript-config/base.json sets lib: ["ES2022"] and no types, which is what makes a stray fs/Buffer/__dirname in a platform-agnostic package a compile error (ADR-0012). That guardrail was untested until QRS-015, and four packages then failed on console, process and setTimeout. Adding @types/node would have deleted the guardrail to silence the symptom — instead tooling/typescript-config/platform-globals.d.ts declares exactly the cross-runtime subset (Hermes + browser + Node) and nothing more, and each package includes it. packages/domain is the exception: it sets "types": ["node"] for its co-located node:test files, so it must NOT also include that file (duplicate console declarations are an error).

legacy/ keeps its own vite.config.js / jsconfig.json / components.json (shadcn "new-york") — reference only.

Naming conventions — the operating-manual statement ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "Naming conventions [ENFORCED]" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

Naming conventions [ENFORCED] ​

Components PascalCase (ReminderRow.tsx) · hooks use + camelCase (useReminders.ts) · data-layer contracts {Domain}Service in packages/data/src/{domain}/service.ts (+ service.stub.ts) · tables snake_case (reminder_occurrences) · RPCs get_{entity} / get_{feature}_summary (get_reminders) · Edge Functions kebab domain-action / domain-noun-verb (manage-reminder) · EDGE_FN keys UPPER_SNAKE_CASE · migrations YYYYMMDDHHMMSS_name.sql · tracker ids QRS-### · ADRs NNNN-kebab-title.md.

SQL function naming — FIVE families, not one [amended 2026-08-08 after a compliance audit]. The line above said only get_{entity}, and the v2 baseline has twenty functions in five distinct families (⚠ "fifteen" until 2026-08-28). Rather than force every one into get_* — which would have made a boolean predicate and a multi-scope resolver look like a row fetch — the convention is widened, because the prefix should tell a reader what KIND of work happens:

FamilyMeansExamples
get_*Retrieve stored rows, possibly projectedget_industries · get_platform_plans · get_public_setu_card · get_public_catalogue · get_my_features
resolve_*Derive an answer by computation over several sourcesresolve_features (8 scopes × 3 axes) · resolve_workspace_plan
is_*Predicate returning a bare booleanis_slug_reserved
my_*RLS helper scoped to auth.uid(), called from inside policiesmy_workspace_ids · my_oversight_workspace_ids · my_shared_org_ids
{table}_{action}Trigger function, named for what it guardsworkspaces_maintain_path · setu_cards_slug_write_once · industries_validate_primitives

Two of these earn their place rather than merely differing: resolve_* signals that the result is computed and not stored, so a reader does not go looking for a table behind it; and my_* reads correctly at the call site inside a policy (workspace_id = any (my_workspace_ids())), where get_my_* would add noise to the most performance-sensitive expressions in the schema. Anything outside these five is a naming violation, not a sixth family — pick the one that describes the work.

Table names must be domain-specific, never generic [ENFORCED — 2026-08-08]. cards became setu_cards, plans became platform_plans, primitives became process_primitives, archetypes became business_archetypes. The test is "will a second thing plausibly want this name?" — QRSETU is expected to grow vCards, invitation cards and event cards, so cards was ambiguous on arrival; a dairy has meal plans and a gym has membership plans, so plans was too; and primitives collided with apps/mobile/src/ui, which this file itself calls "the systemic RN primitive set" — the same word meaning two unrelated things inside one repo. Column names stay short (archetype_key, plan_key, primitive_key): the table name carries the disambiguation, and a business_archetype_key FK column would be verbose without adding clarity.