These rules apply to ALL code written in this project. Read docs/references/typescript-patterns.md before any refactoring session or when writing complex logic.
- TypeScript strict mode (
"strict": true). Zeroany— useunknown+ type guards instead. - Target: ES2017+. Use modern syntax: optional chaining, nullish coalescing,
satisfies,usingfor cleanup. - Node 20+, Next.js 15 (App Router), React 19.
Type assertions bypass the compiler. Use type guards or discriminated unions instead:
// ❌ NEVER
const cat = value as CardCategory;
// ✅ ALWAYS
function isCardCategory(v: string): v is CardCategory {
return CARD_CATEGORIES.includes(v as CardCategory);
}
if (isCardCategory(value)) {
/* value is now CardCategory */
}They crash at runtime. Use optional chaining + nullish coalescing or early returns:
// ❌ NEVER
const name = card!.name;
// ✅ ALWAYS
const name = card?.name ?? "Unknown";Every disable MUST have a comment explaining WHY and linking to the issue that will fix it:
// eslint-disable-next-line react-hooks/exhaustive-deps -- #42: searchSignal is a ref, not stateEvery switch on a union type MUST have an exhaustive check:
function getPairingLabel(type: CommanderPairingType): string {
switch (type) {
case "none":
return "Aucun";
case "partner":
return "Partner";
// ... all cases
}
const _exhaustive: never = type;
return _exhaustive;
}Prefer { role: "commander" } over { isCommander: true, isPartner: false }:
// ❌ Allows invalid states (isCommander && isPartner both true)
interface DeckCard {
isCommander: boolean;
isPartner: boolean;
}
// ✅ Only valid states are representable
type CardRole = "commander" | "partner" | "companion" | "main";
interface DeckCard {
role: CardRole; /* ... */
}Arrays and objects returned from stores or utils should be readonly:
function getCards(): readonly DeckCard[] {
/* ... */
}Never iterate the same array multiple times when one pass suffices:
// ❌ O(n × categories) — iterates allCards once per category
const lands = allCards
.filter((c) => c.category === "land")
.reduce((s, c) => s + c.quantity, 0);
const creatures = allCards
.filter((c) => c.category === "creature")
.reduce((s, c) => s + c.quantity, 0);
// ✅ O(n) — single pass
const counts = new Map<CardCategory, number>();
for (const card of allCards) {
counts.set(card.category, (counts.get(card.category) ?? 0) + card.quantity);
}If you .find() or .includes() on an array more than once, convert to Map/Set first:
// ❌ O(n) per lookup
const isBanned = bannedCards.find((c) => c.name === name);
// ✅ O(1) per lookup
const bannedSet = new Set(bannedCards.map((c) => c.name));
const isBanned = bannedSet.has(name);.toLowerCase(), .normalize(), JSON parse — do them once, cache the result:
// ❌ Recomputes on every theme × every card
themes.forEach((t) =>
cards.forEach((c) => {
if (c.oracle_text.toLowerCase().includes(t.keyword.toLowerCase())) {
/* ... */
}
})
);
// ✅ Pre-lowercased
const lowerTexts = cards.map((c) => ({
...c,
lowerOracle: c.oracle_text.toLowerCase(),
}));
const lowerKeywords = themes.map((t) => ({
...t,
lowerKw: t.keyword.toLowerCase(),
}));// ❌ Bug-prone: && binds tighter than ||
return (a && b) || (c && d);
// ✅ Intent is clear
return (a && b) || (c && d);Any computation on arrays/objects in a component MUST be wrapped in useMemo:
const cardsByCategory = useMemo(() => {
const result: Record<CardCategory, DeckCard[]> = {};
for (const card of deck.cards) {
(result[card.category] ??= []).push(card);
}
return result;
}, [deck.cards]);const handleAddCard = useCallback(
(card: ScryfallCard) => {
addCard(card);
},
[addCard]
);Every route segment and every panel that fetches data MUST have an Error Boundary:
// app/builder/[deckId]/error.tsx — Next.js convention
"use client";
export default function BuilderError({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorPanel error={error} onRetry={reset} />;
}If a component exceeds 200 lines, extract sub-components or custom hooks. God components are forbidden.
Use React Context or Zustand selectors instead:
// ❌ page → Panel → SubPanel → Card (3 levels of cardActions)
// ✅ const { addCard } = useDeckStore(s => ({ addCard: s.addCard }));Split large stores into focused slices:
useDeckStore— deck CRUD + active deckuseCardStore— card operations, undo/redouseUIStore— view modes, preferences
Never const store = useDeckStore() — always select what you need:
// ❌ Re-renders on ANY store change
const store = useDeckStore();
// ✅ Re-renders only when cards change
const cards = useDeckStore((s) => s.activeDeck?.cards ?? []);Race conditions with global variables are bugs. Use a Promise chain:
// ❌ Race condition when multiple calls overlap
let lastTime = 0;
async function rateLimited() {
const wait = Math.max(0, 100 - (Date.now() - lastTime));
await delay(wait);
lastTime = Date.now();
return fetch(/* ... */);
}
// ✅ Serialized queue — no overlap possible
let queue = Promise.resolve();
function rateLimited(url: string) {
return (queue = queue.then(async () => {
const wait = Math.max(0, 100 - (Date.now() - lastTime));
await delay(wait);
lastTime = Date.now();
return fetch(url);
}));
}Return proper HTTP error codes, never let exceptions bubble:
export async function GET(req: NextRequest) {
try {
// ...
return NextResponse.json(data);
} catch (error) {
console.error("[GET /api/decks]", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}- Files:
kebab-case.tsfor utils,PascalCase.tsxfor components - Types/Interfaces:
PascalCase, prefix interfaces with context (DeckCard, notCard) - Constants:
SCREAMING_SNAKE_CASEin aconstants/folder - No magic numbers — extract to named constants with JSDoc:
/** Scryfall rate limit: max 10 requests per second */
const SCRYFALL_MIN_DELAY_MS = 100;- No
console.login production code. Use aloggerutility or remove before commit.
- Every pure function in
lib/MUST have unit tests (vitest) - Test file lives next to source:
validation.ts→validation.test.ts - Use
describe/itpattern with descriptive names:
describe("validatePartner", () => {
it("rejects non-Partner cards as partner", () => {
/* ... */
});
it("allows generic Partner keyword pairing", () => {
/* ... */
});
it("validates Partner with specific name match", () => {
/* ... */
});
});Feature branches must NOT modify docs/project/changelog.md, docs/project/progress.md, or docs/product/roadmap.md.
These files are updated in a dedicated chore/docs or docs/changelog-update PR after each merge batch.
This prevents rebase conflicts when multiple branches touch the same doc files.
Checklist (run mentally or via script):
npx tsc --noEmitpassespnpm lintpasses (zero warnings)pnpm testpassespnpm sonarruns successfully (or rely on CI if it hangs locally)- SonarCloud open issues are 0:
https://sonarcloud.io/project/issues?issueStatuses=OPEN&id=KaelSensei_MagicAIBuilderhttps://sonarcloud.io/api/issues/search?componentKeys=KaelSensei_MagicAIBuilder&statuses=OPEN&ps=100
- No
any, noas, no!, noeslint-disablewithout justification - No
console.logleft - No file > 300 lines
- All new functions have JSDoc with
@paramand@returns - Derived state is memoized in components
For detailed patterns, anti-pattern examples with before/after code, and the full audit of existing issues to fix, read:
→ docs/references/typescript-patterns.md