Files
kolaytercih/scripts/tasarim-denetim.ts

267 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
};
}
/** <Etiket …>…</Etiket> 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"], /<Sparkles\b/),
},
{
ad: "yabanci-notr",
seviye: "hata",
mesaj: "Nötr renk yalnızca slate; gray/zinc/neutral/stone yazılmaz (DESIGN.md §2)",
bul: regexKurali(new RegExp(`\\b${RENK_ONEK}-(?:gray|zinc|neutral|stone)-\\d+`)),
},
{
ad: "yabanci-renk",
seviye: "hata",
mesaj: "Paletin dışında ton; primary mavi / orange-500 / slate / risk renkleri kullanılır (DESIGN.md §2)",
// İstisna (DESIGN.md §2 "Gösterge karesi"): yalnız dolu bg-indigo-500 ve
// bg-green-600 zeminleri serbest; başka ton/opaklık/özellik yine hata.
bul: regexKurali(
new RegExp(
`\\b${RENK_ONEK}-(?:indigo|purple|fuchsia|pink|cyan|teal|lime|green|yellow)-\\d+(?!\\d)(?!(?<=\\bbg-(?:indigo-500|green-600))(?![\\w/-]))`,
),
),
},
{
ad: "gradyan-metin",
seviye: "hata",
mesaj: "Gradyan metin yok (DESIGN.md §2)",
bul: regexKurali(/\b(?:bg-clip-text|text-transparent)\b/),
},
{
ad: "parlama-golgesi",
seviye: "hata",
mesaj: "Parlama (glow) gölgesi yok; shadow-sm / hover:shadow-lg shadow-primary/5 (DESIGN.md §4.4)",
bul: regexKurali(/shadow-\[0_0_[1-9]\d*px/),
},
{
ad: "hareket-spam",
seviye: "hata",
mesaj:
"whileHover scale / aos / bounce yok; Reveal, piksel ısısı, kt-unblur (DESIGN.md §7). animate-ping yalnızca canlı göstergesi noktasında",
bul: regexKurali(/whileHover=\{\{\s*scale|data-aos=|\banimate-bounce\b/),
},
{
ad: "kapatma-carpisi",
seviye: "hata",
mesaj: "showCloseButton={false} verilen dosyada kanonik çarpı sınıf seti yok (DESIGN.md §4.3)",
bul: (icerik) => {
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);