/** * Tasarım dili denetimi — docs/tasarim/DESIGN.md kurallarının makineyle * yakalanabilen kısmı. Ajan/insan fark etmez: `pnpm tasarim:denetim`. * * Çıkış kodu: hata varsa 1, yoksa 0. Uyarılar çıkış kodunu etkilemez. * Kapsam: src/**\/*.tsx. Hariç: src/components/ui (shadcn tabanı) ve * src/components/dev (yalnızca geliştirici paneli) — bazı kurallarda. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; type Seviye = "hata" | "uyari"; type Bulgu = { dosya: string; satir: number; kural: string; seviye: Seviye; mesaj: string; parca: string; }; type Kural = { ad: string; seviye: Seviye; mesaj: string; /** ui/dev klasörleri de taransın mı */ uiDahil?: boolean; /** Tam yolu eşleşen dosyalar kuraldan muaf */ muaf?: RegExp; bul: (icerik: string) => Array<{ index: number; parca: string }>; }; const KOK = process.cwd(); const KAYNAK = join(KOK, "src"); const UI_VEYA_DEV = /src\/components\/(ui|dev)\//; function tsxDosyalari(dizin: string): string[] { const sonuc: string[] = []; for (const ad of readdirSync(dizin)) { const yol = join(dizin, ad); if (statSync(yol).isDirectory()) sonuc.push(...tsxDosyalari(yol)); else if (yol.endsWith(".tsx")) sonuc.push(yol); } return sonuc; } function satirNo(icerik: string, index: number): number { let n = 1; for (let i = 0; i < index && i < icerik.length; i++) if (icerik[i] === "\n") n++; return n; } function satirParcasi(icerik: string, index: number): string { const bas = icerik.lastIndexOf("\n", index) + 1; let son = icerik.indexOf("\n", index); if (son === -1) son = icerik.length; return icerik.slice(bas, son).trim().slice(0, 110); } /** Basit regex kuralı: her eşleşme bir bulgu. */ function regexKurali(re: RegExp) { return (icerik: string) => { const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g"); const sonuc: Array<{ index: number; parca: string }> = []; for (const m of icerik.matchAll(g)) sonuc.push({ index: m.index ?? 0, parca: m[0] }); return sonuc; }; } /** … bloklarının içinde `ic` deseni varsa bulgu. */ function blokIcinde(etiketler: string[], ic: RegExp) { return (icerik: string) => { const sonuc: Array<{ index: number; parca: string }> = []; for (const etiket of etiketler) { const blok = new RegExp(`<${etiket}\\b[\\s\\S]*?<\\/${etiket}>`, "g"); for (const m of icerik.matchAll(blok)) { const icM = ic.exec(m[0]); if (icM) sonuc.push({ index: (m.index ?? 0) + icM.index, parca: icM[0] }); } } return sonuc; }; } const RENK_ONEK = "(?:bg|text|border|ring|fill|stroke|from|to|via|divide|outline|placeholder|decoration|accent|caret|shadow)"; const KURALLAR: Kural[] = [ { ad: "badge-import", seviye: "hata", mesaj: "Rozet için @/components/ui/badge değil SectionEyebrow kullanılır (DESIGN.md §4.2)", bul: regexKurali(/from\s+["']@\/components\/ui\/badge["']/), }, { ad: "elle-rozet", seviye: "hata", mesaj: "Elle yazılmış rozet; SectionEyebrow'u içe aktar (DESIGN.md §4.2)", muaf: /src\/components\/pixel-decor\.tsx$/, bul: regexKurali(/rounded-full border border-slate-200 bg-white px-3\.5 py-1\.5/), }, { ad: "cta-yildiz", seviye: "hata", mesaj: "CTA butonunda Sparkles/yıldız ikonu olmaz; metin + sondaki ArrowRight (DESIGN.md §4.1)", bul: blokIcinde(["Button", "Link"], / { const m = /showCloseButton=\{false\}/.exec(icerik); if (!m) return []; const kanonik = /size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200/.test(icerik); return kanonik ? [] : [{ index: m.index, parca: m[0] }]; }, }, { ad: "yabanci-font", seviye: "hata", mesaj: "Yalnızca font-heading / font-sans / font-bricolage / font-mono (DESIGN.md §3)", bul: regexKurali(/\bfont-(?:serif|\[)|["'](?:Inter|Roboto|Arial|Helvetica)["']/), }, { ad: "dark-sinif", seviye: "uyari", mesaj: "Üründe karanlık mod yok; dark: sınıfı gereksiz (DESIGN.md §1)", bul: regexKurali(/(?<=[\s"'`])dark:[a-z]/), }, { ad: "hex-renk", seviye: "uyari", mesaj: "Sınıf içinde hex renk; token ya da Tailwind tonu kullan (DESIGN.md §2)", // Rehber kapak görseli OG görseliyle aynı sabit paleti paylaşır (satori // tarafında Tailwind token'ı çözülmez); bilinçli istisna. muaf: /src\/features\/rehber\/components\/rehber-kapak\.tsx$/, bul: regexKurali(/\[#[0-9a-fA-F]{3,8}\]/), }, { ad: "minik-yazi", seviye: "uyari", mesaj: "12 px altı yazı yok; en küçük text-xs, yalnız ince yazıda (DESIGN.md §3 alt sınırlar)", bul: regexKurali(/\btext-\[(?:[0-9]|1[01])px\]/), }, { ad: "buyuk-harf-etiket", seviye: "uyari", mesaj: "Büyük harfli minik etiket yok; büyük-küçük harfli text-sm font-semibold (DESIGN.md §3 alt sınırlar)", bul: regexKurali(/\buppercase\b[^"'`\n]*\btracking-(?:wide|wider|widest)\b|\btracking-(?:wide|wider|widest)\b[^"'`\n]*\buppercase\b/), }, { ad: "mavi-hover", seviye: "uyari", mesaj: "Hover rengi tek turuncu (orange-500); liste satırındaki ad linkiyse istisna, mavi kalır (DESIGN.md §2.1)", // §2.1 istisnası: §4.15 kanonik liste satırı adı (sarar, text-base // font-semibold leading-snug + hover:text-primary hover:underline) mavi // kalır. Eski truncate/text-sm adlar istisnaya girmez — önce §4.15'e // çevrilir. Satırın tamamı link olduğunda group-hover çifti de sayılır. // (Bilal'in kararı, 24 Eyl 2026: istisna uyarı vermesin.) bul: (icerik) => regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/)(icerik).filter((e) => { const bas = icerik.lastIndexOf("\n", e.index) + 1; const son = icerik.indexOf("\n", e.index); const satir = icerik.slice(bas, son === -1 ? undefined : son); return !( /\btext-base font-semibold leading-snug\b/.test(satir) && /\b(?:group-)?hover:text-primary (?:group-)?hover:underline\b/.test(satir) ); }), }, { ad: "iri-sayi-tabular", seviye: "uyari", mesaj: "Cal Sans iri sayıda tabular-nums 'daktilo' gibi durur; yalnız font-sans tablo sütunlarında (DESIGN.md §3)", bul: regexKurali(/\bfont-heading\b[^"'`\n]*\btabular-nums\b|\btabular-nums\b[^"'`\n]*\bfont-heading\b/), }, { ad: "samimiyetsiz-unlem", seviye: "uyari", mesaj: "Hata/bildirim metninde 'Hay aksi/Eyvah/Oops' ya da 'buraya tıkla' yok; ne oldu + ne yapılacak (DESIGN.md §10.4)", bul: regexKurali(/Hay aksi|Eyvah|\bOops|\bUups|[Bb]uraya tıkla|[Tt]ıklayın(?:ız)?\b/), }, { ad: "emoji-ikon", seviye: "uyari", mesaj: "Emoji ikon olarak kullanılmaz; lucide ikon (DESIGN.md §4.7)", bul: (icerik) => // Yorum satırındaki işaretler (// ✗ gibi) ikon değildir regexKurali(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]\uFE0F?/u)(icerik).filter( (e) => !/^(?:\/\/|\*|\/\*)/.test(satirParcasi(icerik, e.index)), ), }, ]; const bulgular: Bulgu[] = []; for (const yol of tsxDosyalari(KAYNAK)) { const gorece = relative(KOK, yol); const icerik = readFileSync(yol, "utf8"); for (const k of KURALLAR) { if (!k.uiDahil && UI_VEYA_DEV.test(gorece)) continue; if (k.muaf && k.muaf.test(gorece)) continue; for (const e of k.bul(icerik)) { bulgular.push({ dosya: gorece, satir: satirNo(icerik, e.index), kural: k.ad, seviye: k.seviye, mesaj: k.mesaj, parca: satirParcasi(icerik, e.index), }); } } } bulgular.sort((a, b) => a.seviye !== b.seviye ? (a.seviye === "hata" ? -1 : 1) : a.dosya.localeCompare(b.dosya) || a.satir - b.satir, ); for (const b of bulgular) { const etiket = b.seviye === "hata" ? "HATA " : "uyarı"; console.log(`${b.dosya}:${b.satir} [${etiket}] ${b.kural} — ${b.mesaj}\n ${b.parca}`); } const hata = bulgular.filter((b) => b.seviye === "hata").length; const uyari = bulgular.length - hata; console.log(`\nTasarım denetimi: ${hata} hata, ${uyari} uyarı (${KURALLAR.length} kural, docs/tasarim/DESIGN.md)`); process.exit(hata > 0 ? 1 : 0);