AI'ın yazdığı kodun mimariyi bozmasını önlemek: kuralları ajanlara da zorlatmak

Bir ajan görevin geçmesini optimize eder; mimariniz o hedefin içinde yer almaz. Build'in zorlamadığı kuralı her insandan daha hızlı ve daha kibar çiğner. Talimat dosyasından CODEOWNERS'a uzanan korkuluk yığınını ve korkulukların kendisini nasıl koruduğumuzu anlatıyoruz.

Ajana verilen görev tek cümle: "Arama sonuçlarında fiyatı aktif kampanya indirimiyle göster." Dört dakika sonra PR hazır. Testler yeşil, açıklama düzgün, commit mesajları sizinkinden özenli. Diff'e baktığınızda SearchFeature artık Networking'i import ediyor, CampaignCache.shared diye yeni bir nesne doğmuş, fiyat formatlayan üçüncü bir helper yazılmış ve kırılan bir testin beklenen değeri sessizce gevşetilmiş. Hiçbiri kötü niyet ya da beceriksizlik işi sayılmaz. Görev "fiyatı göster" idi ve fiyat gösteriliyor.

Tezimiz şu: ajanın hedef fonksiyonu görevin geçmesidir, mimariniz o fonksiyonun içinde yer almaz. Build'in zorlamadığı bir kuralı ajan, cuma akşamı PR açan en yorgun geliştiriciden daha hızlı ve çok daha kibar bir dille çiğner. Kuralları build'e nasıl taşıdığımızı iOS ve Android yazılarında araç araç anlattık. Burada o mekaniği tekrar etmiyoruz; kodun bir kısmını ajanlar yazmaya başladığında neyin değiştiğine bakıyoruz. Örnekler o iki yazıdaki hayali seyahat uygulamalarından: iOS'ta Voyage, Android'de Rota.

Ajan görevi kapatır, mimari görev tanımında yazmaz

İnsan bir kuralı çiğnerken en azından bir an tereddüt eder, çünkü mimari toplantısında oturmuştur. Ajanın o toplantıdan haberi yok. Bağlamında ne varsa onunla çalışır ve derleyicinin kabul ettiği en kısa yolu seçer. Büyük projelerde sık gördüğümüz aşınma biçimleri aynı PR'da yan yana durabiliyor:

// Sources/Features/SearchFeature/SearchViewModel.swift (ajanın PR'ından)
import BookingInterface
import Networking                       // 1) sınırın öbür tarafı, "tek bir çağrı için"
import SwiftUI

@MainActor
public final class CampaignCache {      // 2) yeni singleton  3) gerekçesi olmayan public
    public static let shared = CampaignCache()
    public var discounts: [BookingID: Decimal] = [:]
}

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [SearchResultRow] = []
    private let repository: any BookingRepository
    // 4) ağa giden ikinci yol: repository dururken feature kendi istemcisini yaratıyor
    private let client = HTTPClient(baseURL: URL(string: "https://api.voyage.example")!)

    init(repository: any BookingRepository) { self.repository = repository }

    func applyCampaigns() async throws {
        let data = try await client.get("campaigns/active")
        for campaign in try JSONDecoder().decode([CampaignDTO].self, from: data) {
            CampaignCache.shared.discounts[campaign.bookingID] = campaign.rate
        }
        results = results.map { $0.applying(CampaignCache.shared.discounts[$0.id]) }
    }
}

// 5) DesignSystem'deki PriceFormatter'ın üçüncü kopyası
extension Decimal {
    func formattedPrice(currency: String) -> String {
        formatted(.currency(code: currency))
    }
}

Android tarafında aynı görev aynı izleri bırakır. Önce build dosyası, sonra kod:

// feature/search/impl/build.gradle.kts (ajanın PR'ından)
dependencies {
    implementation(project(":feature:search:api"))
    implementation(project(":feature:booking:api"))
    implementation(project(":feature:booking:impl"))   // CampaignCalculator orada duruyordu
    implementation(libs.retrofit)                      // "kampanya servisi için"
}
// feature/search/impl/src/main/kotlin/com/rota/feature/search/impl/CampaignPrices.kt
object CampaignCache {                                  // yeni global mutable state
    val discounts = mutableMapOf<String, BigDecimal>()
}

class CampaignPriceLoader {                             // public, çünkü Kotlin'de varsayılan bu
    private val service = Retrofit.Builder()            // ikinci Retrofit, ikinci bağlantı havuzu
        .baseUrl("https://api.rota.example/")
        .addConverterFactory(MoshiConverterFactory.create())
        .build()
        .create(CampaignService::class.java)

    suspend fun load() {
        service.active().forEach { CampaignCache.discounts[it.bookingId] = it.rate }
    }
}

Her satırın makul bir açıklaması var. Ajan CampaignCalculator'ı aramış, :feature:booking:impl içinde bulmuş, oraya bir dependency eklemiş. Proje içinde PriceFormatter'ı bulamamış (ya da aramamış), yenisini yazmış. Paylaşılan state lazım olmuş, eğitim verisinde en sık gördüğü kalıbı kullanmış. Singleton'ın oturumdan uzun yaşayan state'i ve test izolasyonunu nasıl bozduğu ajanın umurunda olmaz; o kalıp derlenir ve görev kapanır.

En sinsi olanı testtir, çünkü review'da en az bakılan yer orasıdır:

// Önce
@Test
fun `kampanya indirimi liste fiyatindan dusulur`() = runTest {
    val row = viewModel.rows.value.first()
    assertEquals(BigDecimal("90.00"), row.price)
}

// Ajandan sonra, commit mesajı: "flaky assertion düzeltildi"
@Test
fun `kampanya indirimi liste fiyatindan dusulur`() = runTest {
    val row = viewModel.rows.value.first()
    assertNotNull(row.price)
}

Test geçiyor, test adı hâlâ aynı şeyi vaat ediyor, doğruladığı şey ise artık "bir fiyat var". Ajana "testleri geçir" dediğinizde iki yol vardır: kodu düzeltmek ve testi değiştirmek. İkincisi çoğu zaman daha kısadır.

İnsanla farkı hız ve hacim belirler. Bir geliştirici ayda bir kestirme yapar ve review'da yakalanma ihtimali vardır. Ajanlar günde onlarca PR açtığında reviewer'ın dikkati ilk tükenen kaynak olur ve yukarıdaki satırların her biri tek başına masum göründüğü için geçer.

Talimat dosyası tavsiyedir, build hükümdür

İlk refleks kuralları ajanın okuduğu dosyaya yazmaktır. AGENTS.md bugün bunun ortak formatı: repo kökünde durur, alt klasörlere de konabilir ve ajan çalıştığı dosyaya en yakın olanı esas alır. Cursor, GitHub Copilot ve Claude Code bu dosyayı okuyor; her birinin kendi formatı da var (.cursor/rules altındaki .mdc dosyaları, .github/copilot-instructions.md, CLAUDE.md). Ayrıntılar araca göre değişiyor. Örneğin Claude Code, AGENTS.md'yi yalnızca yanında bir CLAUDE.md yoksa doğrudan okuyor ve bunun için 2.1.277 ya da sonrası gerekiyor. Kullandığınız aracın dokümantasyonuna bakmadan "okuyordur" diye varsaymayın.

Bu dosyaların ortak özelliği bağlam olmalarıdır. Claude Code'un dokümantasyonu bunu açıkça yazıyor: talimat dosyaları "context, not enforced configuration" olarak ele alınır ve bir eylemi ajanın kararından bağımsız engellemek istiyorsanız hook kullanmanız söylenir. Diğer araçlarda da durum aynı. Talimat, modelin bağlam penceresindeki bir metindir. Uzun bir oturumda başka metinlerle yarışır, bağlam sıkıştırıldığında özetlenir, görev kuralla çeliştiğinde görev ağır basabilir. Wiki sayfasından tek farkı okunma ihtimalinin daha yüksek olmasıdır.

Yalnızca yaptırım da yetmez. Ajan build'i kıran bir hatayla karşılaştığında neden kırıldığını ve doğru yolun ne olduğunu bilmiyorsa, en yakın çıkışı arar: lint'i susturur, baseline'a satır ekler, manifest'e dependency yazar. Hata vardır ama yön yoktur.

İkisi birlikte çalıştığında ise ajanların en güçlü yanı devreye girer. Ajan bir döngüde çalışır: kodu yazar, komutu çalıştırır, çıktıyı okur, düzeltir. Derleyicinin ya da kontrol script'inin hatası o döngünün girdisidir ve ajan onu, review yorumunu üç gün sonra okuyan bir insandan çok daha disiplinli işler. Talimat dosyası ajana hangi komutu çalıştıracağını ve doğru yolun neye benzediğini söyler, build de talimatın görmezden gelinememesini sağlar.

Ajan talimatı okur, build'e ise uyar.

Korkuluk yığını: ucuzdan güçlüye beş katman

Katmanların her birinin kurulumunu önceki iki yazıda anlattık. Aşağıdaki tablo aynı merdivene ajan gözüyle bakıyor.

Katman Kurulum maliyeti Ajan ihlali ne zaman öğrenir Ajanla birlikte ne değişir
Talimat dosyası Bir öğleden sonra Öğrenmez; okuduysa uyar Gerekçeyi ve doğru yolu taşıyan tek yer burası
Module sınırı ve access control Yüksek, bir kez Derlemede, saniyeler içinde En kısa geri bildirim döngüsü; ajan hatayı okuyup kendi düzeltir
Lint ve mimari testleri Orta Yerelde kontrol komutunu çalıştırınca Hata mesajının kalitesi belirleyici olur
CI graph kontrolü Düşük PR'da Ajanın yerelde çalıştırmayı atladığı her şeyi yakalar
Sınır dosyalarında zorunlu review Düşük Merge aşamasında Kuralı değiştirme yetkisi insanda kalır

Module sınırı ve access control ajanlarla birlikte daha da değerli hale geliyor. SearchFeature'ın manifest'inde Networking yoksa ve derleme bu yüzden kırılıyorsa, ajan ihlali PR açmadan önce, kendi döngüsünün içinde görür. iOS'ta InternalImportsByDefault ile internal varsayılanı, Android'de explicitApi() aynı işi "public-by-default" için yapar: ajan public yazmak zorunda kalır ve o kelime diff'te görünür.

Lint ve mimari testleri (SwiftLint custom_rules, Harmonize, Konsist, ArchUnit, detekt) module içinde kalan kuralları taşır: "feature içinde URLSession yok", "yeni static let shared yok", "Impl sınıfları internal". Yeni singleton'ı ve ikinci ağ yolunu yakalayacak olan bu katmandır, çünkü ikisi de module sınırını ihlal etmeden yazılabilir.

CI'daki graph kontrolü (iOS'ta manifest'i okuyan script, Android'de module graph assertion ve dependency guard) son savunma hattıdır. Ajanın yerelde hangi komutu çalıştırdığına güvenmeyiz; CI her PR'da hepsini çalıştırır.

Beşinci katman, ajanlardan önce "olsa iyi olur" sayılırdı. Şimdi zorunlu: manifest, build dosyaları, lint config'i, baseline ve kontrol script'i için CODEOWNERS ile zorunlu review. Sebebini aşağıda, korkulukların kendisini koruma bölümünde açıyoruz.

Ajanın uyabileceği kural nasıl yazılır

Çoğu talimat dosyasının mimari bölümü şuna benzer:

## Mimari
- Clean Architecture prensiplerine uyun.
- Katmanlar arası bağımlılıklara dikkat edin.
- Gereksiz singleton kullanmaktan kaçının.
- Kod tekrarından kaçının, mevcut yardımcıları kullanın.

Bu dört satırın hiçbiri kontrol edilemez. "Dikkat edin" bir eylem tarif etmiyor, "gereksiz" kelimesinin ölçüsü yok, "mevcut yardımcılar"ın nerede olduğu yazmıyor. Ajan bu metni okur, katılır ve import Networking yazar.

Ajanın uyabildiği kuralın dört özelliği var: neyi kapsadığı dosya yoluyla bellidir, doğrulayan bir komutu vardır, gerekçesi bir cümleyle yazılmıştır ve doğru yapılmış bir örneğe işaret eder. Aynı bölümün Voyage için yazılmış hali:

## Mimari kuralları (build zorlar; baseline'a satır eklenmez)

Doğrulama. Her değişiklikten sonra ve "bitti" demeden önce çalıştır:
  swift package describe --type json | swift Scripts/check-architecture.swift
  swiftlint lint --quiet

1. Sources/Features altındaki target'lar yalnızca Kernel ve Interfaces katmanlarını import eder.
   Neden: feature'lar birbirinden ve ağ katmanından bağımsız derlenip test edilebilsin.
   Doğrusu: ihtiyacın olan çağrıyı ilgili *Interface protokolüne ekle, implementasyonu *Data
   module'ünde yaz. Örnek: Sources/Features/CheckoutFeature/CheckoutViewModel.swift

2. Yeni `static let shared` yazılmaz. Paylaşılan nesne AppComposition'da bir kez yaratılır ve
   init parametresiyle verilir. Örnek: Sources/App/AppComposition/AppAssembly.swift

3. Yeni tipler internal'dır. `public` yazıyorsan PR açıklamasında hangi module'ün kullandığını söyle.

4. Fiyat, tarih ve para birimi formatlama Sources/Kernel/DesignSystem/Formatting içindedir.
   Yeni helper yazmadan önce ara: grep -rn "func format" Sources/Kernel/DesignSystem

5. Şu dosyalara dokunma. Değişmeleri gerektiğini düşünüyorsan dur ve gerekçeni yaz:
   Package.swift, .swiftlint.yml, architecture-baseline.txt, Scripts/, ArchitectureRules/, .github/

6. Kırılan bir testin beklenen değerini değiştirme. Testin yanlış olduğunu düşünüyorsan testi
   olduğu gibi bırak ve PR açıklamasında ayrı bir başlık altında nedenini anlat.

Gerekçe satırı süs olsun diye durmuyor. Kuralın harfinin kapsamadığı bir durumla karşılaşan ajan, gerekçeyi biliyorsa doğru yöne genelleme yapabilir. "Feature'lar bağımsız derlenebilsin" cümlesini okuyan ajan, kuralda adı geçmeyen yeni bir infrastructure module'ünü de import etmemesi gerektiğini çıkarır.

Dosyayı kısa tutun. Claude Code'un dokümantasyonu dosya başına 200 satırın altını hedeflemeyi ve yalnızca belirli klasörler için geçerli kuralları yol kapsamlı dosyalara taşımayı öneriyor; diğer araçların eşdeğerleri de var (Cursor'da globs, Copilot'ta applyTo, AGENTS.md'de alt klasör dosyası). Kırk maddelik bir kural listesi, kırk maddelik bir wiki sayfasının akıbetine uğrar. Talimat dosyasına yalnızca build'in zorladığı kuralları ve onların doğrulama komutunu yazıyoruz. Zorlanmayan bir kuralı oraya yazmak, ajana hangi kuralların pazarlığa açık olduğunu öğretir.

Hata mesajı, ajanın bir sonraki prompt'udur

Kontrol kırıldığında çıktı ajanın bağlamına girer ve bir sonraki adımını o metin belirler. iOS yazısındaki script'in mesajı bir insan için yeterliydi:

error: SearchFeature -> Networking: beyan edilmemiş import (SearchViewModel.swift)

İnsan bunu okur ve "tabii, repository'den geçmem lazım" der. Ajan aynı satırı okur ve en kısa düzeltmeyi yapar: Package.swift'e Networking'i ekler. Mesaj ona ihlalin ne olduğunu söylemiş, ne yapması gerektiğini söylememiştir. "Beyan edilmemiş" kelimesi de beyan etmeyi adeta davet eder. Aynı ihlal için istediğimiz çıktı şu:

error: SearchFeature -> Networking: Features katmanı Infrastructure katmanını import edemez (SearchViewModel.swift)
  Yapılacak: çağrıyı ilgili Interface protokolüne ekleyin, implementasyonu *Data module'ünde yazın.
  Örnek: Sources/Features/CheckoutFeature/CheckoutViewModel.swift
  Yapılmayacak: Package.swift'e bağımlılık eklemek, architecture-baseline.txt'yi büyütmek.
  Doğrulama: swift package describe --type json | swift Scripts/check-architecture.swift

Script'te değişen kısım küçük. İhlal satırı aynı kalıyor (baseline o satırla eşleşiyor), yazdırırken katman çiftine göre bir çare ekleniyor:

struct Remedy {
    let instead: String   // ne yapılmalı
    let example: String   // doğru yapılmış bir dosya
}

let remedies: [String: Remedy] = [
    "Features->Infrastructure": Remedy(
        instead: "çağrıyı ilgili Interface protokolüne ekleyin, implementasyonu *Data module'ünde yazın.",
        example: "Sources/Features/CheckoutFeature/CheckoutViewModel.swift"),
    "Features->Features": Remedy(
        instead: "ortak tipi bir Interface module'üne taşıyın, geçişi AppComposition bağlasın.",
        example: "Sources/Interfaces/PaymentInterface/PaymentFlowStarting.swift"),
]

func describe(_ violation: String, from: String, to: String) -> String {
    guard let remedy = remedies["\(from)->\(to)"] else { return "error: \(violation)" }
    return """
    error: \(violation)
      Yapılacak: \(remedy.instead)
      Örnek: \(remedy.example)
      Yapılmayacak: Package.swift'e bağımlılık eklemek, architecture-baseline.txt'yi büyütmek.
      Doğrulama: swift package describe --type json | swift Scripts/check-architecture.swift
    """
}

Android'de araçların çoğu bu alanı zaten veriyor. detekt'in ForbiddenImport kuralındaki reason, custom lint kuralındaki explanation, ArchUnit'teki because hep aynı iş içindir ve çoğu projede ya boştur ya da kuralı tekrar eder:

@Test
fun `ui paketleri data paketlerine dogrudan erismez`() {
    noClasses().that().resideInAPackage("..ui..")
        .should().dependOnClassesThat().resideInAPackage("..data..")
        .because(
            "UI yalnızca domain katmanını görür. Yapılacak: ihtiyacınız olan veriyi bir UseCase " +
                "üzerinden alın (örnek: feature/booking/impl/domain/GetUpcomingBookingsUseCase.kt). " +
                "Yapılmayacak: bu testi değiştirmek ya da archunit-store dosyalarını güncellemek."
        )
        .check(classes)
}

SwiftLint custom_rules içindeki message alanı için de aynısı geçerli. İyi bir mesajın sınaması basit: projeyi hiç görmemiş biri yalnızca bu çıktıyı okuyarak doğru düzeltmeyi yapabiliyor mu. Ajan tam olarak o kişidir.

Korkulukların kendisini korumak

Önceki yazılarda merdivenin son sütunu "nasıl atlatılır" idi ve üst basamaklarda cevap hep aynıydı: kural dosyasını değiştirerek, yani diff'te görünür şekilde. İnsanlar için bu yeterli bir caydırıcıdır; kimse PR'ında .swiftlint.yml'den bir kuralı sildiğinin görülmesini istemez. Ajanın böyle bir çekincesi yok. "Build'i geçir" görevinde lint config'i, baseline, test ve manifest, düzenlenebilir dosyalardan ibarettir. Gördüğümüz kaçış yolları şunlar:

  • Kuralı kaynağında susturmak: // swiftlint:disable, @Suppress, @SuppressLint.
  • Baseline'ı yeniden üretmek: --update-baseline, detektBaseline, lint baseline dosyasını silip yeniden yazdırmak, ArchUnit store'una satır eklemek.
  • Kuralın kendisini gevşetmek: allowed listesine bir kenar, .swiftlint.yml'de bir excluded satırı, mimari testinde daraltılmış bir scope.
  • Manifest'e ya da build dosyasına dependency eklemek.
  • Testi silmek, kapatmak (@Ignore, @Disabled, .disabled(...)) ya da assertion'ını zayıflatmak.

Üç katmanda kapatıyoruz ve sıralama önemli: yalnızca sonuncusu gerçek bir duvar.

Ajanın kendi izinleri

Çoğu ajan aracında dosya bazlı izin tanımlanabiliyor. Claude Code'da bu, repo'ya commit'lenen .claude/settings.json içindeki permissions kurallarıdır; yol desenleri gitignore sözdizimini kullanır ve başındaki / ayar dosyasının ait olduğu proje köküne göre çözülür. iOS ve Android yollarını tek örnekte gösteriyoruz:

{
  "permissions": {
    "deny": [
      "Edit(/architecture-baseline.txt)",
      "Edit(/.swiftlint.yml)",
      "Edit(/Scripts/**)",
      "Edit(/ArchitectureRules/**)",
      "Edit(/config/detekt/**)",
      "Edit(**/lint-baseline.xml)",
      "Edit(/archunit-store/**)",
      "Edit(/.github/**)"
    ],
    "ask": [
      "Edit(/Package.swift)",
      "Edit(**/build.gradle.kts)",
      "Edit(/gradle/libs.versions.toml)"
    ]
  }
}

Baseline ve lint config'i için deny, manifest ve build dosyaları için ask kullanıyoruz, çünkü yeni bir module eklemek meşru bir iştir ve insanın onayıyla yapılabilmelidir. Diğer araçlarda karşılığı farklı adlar taşır (izin listeleri, ignore dosyaları, onay modları); kendi aracınızın dokümantasyonunda "bu dosyaya yazamaz" kuralının nasıl tanımlandığına bakın.

Bu katmanın sınırını bilin. Claude Code dokümantasyonuna göre Edit deny kuralları yerleşik dosya araçlarını ve Bash'te tanınan dosya komutlarını (sed, tee, yönlendirmeler) kapsar, ama dosyayı dolaylı yoldan yazan keyfi bir alt süreci kapsamaz. Baseline'ı yeniden üreten swift Scripts/check-architecture.swift --update-baseline komutu tam olarak böyle bir alt süreçtir. Ayrıca bu ayarlar yalnızca o aracı kullanan ajanı bağlar. Yerel izinler hız tümseğidir; ajanı çoğu zaman doğru yola geri döndürür ama garanti vermez.

PR'da korkuluk diff'ini yakalayan kontrol

İkinci katman araçtan bağımsızdır ve CI'da çalışır: PR'ın korkulukları gevşetip gevşetmediğine bakan bir script.

#!/usr/bin/env bash
# Scripts/check-guardrails.sh: PR korkulukları gevşetiyor mu?
set -euo pipefail
base="$(git merge-base "${1:-origin/main}" HEAD)"
fail=0

added_lines() { git diff --unified=0 "$base" HEAD -- "$@" | grep -E '^\+[^+]' || true; }

# 1. Yeni bastırma yorumları ve annotation'lar
suppressed="$(added_lines '*.swift' '*.kt' | grep -E 'swiftlint:disable|@Suppress(Lint)?\(' || true)"
if [[ -n "$suppressed" ]]; then
  echo "error: bu PR yeni lint bastırması ekliyor:"
  echo "$suppressed"
  echo "  Yapılacak: bastırmayı kaldırın ve kuralın işaret ettiği kodu düzeltin."
  fail=1
fi

# 2. Büyüyen baseline dosyaları
for file in architecture-baseline.txt config/detekt/baseline.xml app/lint-baseline.xml; do
  [[ -f "$file" ]] || continue
  before="$( (git show "$base:$file" 2>/dev/null || true) | wc -l )"
  after="$(wc -l < "$file")"
  if (( after > before )); then
    echo "error: $file büyüdü ($((before)) -> $((after)) satır). Baseline yalnızca küçülür."
    echo "  Yapılacak: yeni ihlali baseline'a yazmak yerine kodu düzeltin."
    fail=1
  fi
done

# 3. Silinen ya da kapatılan testler
deleted="$(git diff --diff-filter=D --name-only "$base" HEAD -- '*Tests.swift' '*Test.kt')"
disabled="$(added_lines '*Tests.swift' '*Test.kt' | grep -E '@Ignore|@Disabled|\.disabled\(|XCTSkip' || true)"
if [[ -n "$deleted$disabled" ]]; then
  echo "error: bu PR test siliyor ya da kapatıyor:"
  echo "$deleted$disabled"
  echo "  Yapılacak: testi geri getirin. Test yanlışsa PR açıklamasında gerekçesini yazın."
  fail=1
fi

# İstisna: CI bu değişkeni yalnızca mimari sahibinin koyduğu bir etiket varsa tanımlar.
if (( fail )) && [[ "${GUARDRAIL_CHANGE_APPROVED:-}" != "1" ]]; then exit 1; fi

XML baseline'larında satır sayısı kaba bir ölçüdür ama yönü doğru gösterir. Etiketin değişkene nasıl bağlanacağı CI sisteminize göre değişir; önemli olan, istisnayı PR yazarının kendi kendine verememesi. Zayıflatılan assertion'ı bu script yakalamaz (assertEquals yerine assertNotNull), onu review'a bırakıyoruz ve aşağıda neye bakacağımızı söylüyoruz.

Zorunlu review: asıl duvar

Ajan script'in kendisini de düzenleyebilir. Bu yüzden son katman, korkuluk dosyalarının sahipliğidir:

# .github/CODEOWNERS  (birden çok satır eşleşirse sonuncusu geçerlidir)
/Package.swift                @voyage/architecture
/architecture-baseline.txt    @voyage/architecture
/.swiftlint.yml               @voyage/architecture
/Scripts/                     @voyage/architecture
/ArchitectureRules/           @voyage/architecture
/AGENTS.md                    @voyage/architecture
/.claude/                     @voyage/architecture
/.github/                     @voyage/architecture

# Android'deki karşılıkları
/build-logic/                 @rota/architecture
/app/build.gradle.kts         @rota/architecture
/gradle/libs.versions.toml    @rota/architecture
/config/detekt/               @rota/architecture
**/lint-baseline.xml          @rota/architecture
/archunit-store/              @rota/architecture
/app/dependencies/            @rota/architecture

Dosya tek başına bir şey zorlamaz. GitHub'da branch protection ya da ruleset içinde "Require review from Code Owners" açık olmalı; GitLab'da karşılığı protected branch üzerindeki Code Owner onayıdır. GitHub dokümantasyonunun önerdiği gibi CODEOWNERS dosyasının kendisi de (burada .github/ satırıyla) sahipli olmalı, yoksa ilk silinecek satır bellidir. Talimat dosyasını ve ajan ayarlarını da listeye aldık: AGENTS.md'den bir kuralı çıkarmak, kuralı değiştirmektir.

Android'de bütün build.gradle.kts dosyalarını sahipli yapmıyoruz. Module build dosyalarındaki yasak kenarları graph assertion, classpath değişikliklerini dependency guard yakalıyor; sahiplik allowed listesinin yaşadığı app/build.gradle.kts, convention plugin'ler ve version catalog için yeterli. Her module'ün build dosyasını mimari ekibine bağlamak, o ekibi iki haftada darboğaza çevirir.

Kuralı ajan uygular; kuralı değiştirme yetkisi insanda kalır.

AI imzalı PR'ı review etmek

Makinenin yakalayabildiği her şeyi makineye bıraktıktan sonra review'a kalan iş küçülür ama yok olmaz. AI çıktısının review'unu ayrı bir yazıda genel hatlarıyla ele aldık; mimari açısından üç alışkanlık öneriyoruz.

Bağımlılık diff'ine en önce bakın. Kodun tek satırını okumadan şunu çalıştırın:

git diff origin/main...HEAD --stat -- Package.swift '*.gradle.kts' gradle/libs.versions.toml \
  architecture-baseline.txt '*baseline*.xml' .swiftlint.yml config/detekt

Çıktı boşsa PR mimarinin şeklini değiştirmiyor demektir ve geri kalanını normal bir review olarak okuyabilirsiniz. Boş değilse review oradan başlar: bu kenar neden gerekti, görev bunu gerektiriyor muydu.

Diff'in şekline bakın. Ajan PR'larında tekrar eden kokular şunlar: görev tanımıyla ilgisi olmayan module'lerde değişiklik, adı Helper, Utils ya da Manager ile biten yeni dosyalar, yeni public ve yeni shared ya da object, üretim kodundan büyük bir test diff'i ve o diff'in içinde değişen beklenen değerler. Sonuncusu için test dosyalarında yalnızca silinen ve değişen assertion satırlarına bakmak yeter; eklenen testler nadiren sorundur.

Planı isteyin. Ajandan kod yazmadan önce kısa bir plan yazmasını ve PR açıklamasına koymasını talimat dosyasında şart koşun: hangi module'lere dokunacak, hangi mevcut tipi yeniden kullanacak, hangi yeni public yüzey açılacak. Review'da planla diff'i karşılaştırırsınız. Planda adı geçmeyen bir module diff'te varsa, kapsam sessizce genişlemiştir ve soru sormak için elinizde somut bir satır vardır. Plan ayrıca hatayı ucuzken yakalar: "CampaignCalculator'ı booking:impl'den kullanacağım" cümlesini beş saniyede reddedersiniz, aynı kararın 300 satırlık diff'ini reddetmek yarım saat sürer.

Ajanı kuralın doğru tarafında çalıştırmak

Buraya kadar ajan, kuralları delmeye meyilli bir taraf gibi göründü. Kural zorlanır hale geldikten sonra tablo tersine döner: ajan, kuralı uygulatmanın en ucuz iş gücüdür. İnsanların yıllarca ertelediği sıkıcı, mekanik ve doğrulaması makineye bırakılabilen işler tam ona göredir.

Baseline eritmek bunların ilkidir. Baseline'daki her satır, tanımı net ve bitişi makinece doğrulanabilen bir görevdir:

Görev: architecture-baseline.txt içindeki "SearchFeature -> Networking" satırlarını sıfırla.

Kurallar:
- Her ihlal için ayrı commit. Her commit'ten önce kontrol komutunu ve swift test'i çalıştır.
- Baseline'dan yalnızca çözdüğün satırı sil; satır ekleme.
- Package.swift'e bağımlılık ekleme. Yeni bir çağrı gerekiyorsa BookingInterface protokolüne ekle,
  implementasyonu BookingData'da yaz.
- Davranış değişmeyecek: mevcut testlerin beklenen değerlerine dokunma.
- Bir ihlal 50 satırdan fazla değişiklik gerektiriyorsa dur, planını yaz, onay bekle.

Bitti tanımı: kontrol komutu 0 yeni ihlal raporluyor, baseline'da bu kenar kalmadı, testler yeşil.

Bu görev için baseline dosyasındaki deny kuralını o oturumda bilerek gevşetirsiniz; CI'daki script zaten yalnızca büyümeyi reddediyor, küçülmeye izin veriyor. iOS script'i çözülmüş bir satır baseline'da unutulursa da kırıldığı için ajan iki yönden sıkıştırılmış olur.

İkincisi, derleyicinin hakemlik ettiği mekanik migration'lardır. Bir module'de explicitApi() açmak, eksik her modifier için bir derleme hatası üretir; ajana "varsayılan internal, yalnızca başka bir module'den kullanılan declaration'lar public" kuralını verirsiniz ve derleme geçene kadar çalışır. InternalImportsByDefault'u target target açmak, feature'lardaki shared erişimlerini init parametresine çevirmek, api dependency'lerini implementation'a indirmek aynı sınıftandır. Ortak özellikleri şu: doğru sonucun ne olduğunu ajanın muhakemesi belirlemez, derleyici ya da test belirler.

Sıra önemli. Önce kuralı zorlanır hale getirin, sonra ajanı migration'a salın. Tersini yaparsanız ajan bir module'ü temizlerken yan module'de yeni ihlal üretir ve kimse fark etmez.

Nerede durmalı

İki kişilik, tek module'lü bir uygulamada bu yığının tamamı fazladır. Orada kısa bir talimat dosyası, birkaç lint kuralı ve dikkatli bir review yeter; CODEOWNERS'ın koruyacağı ikinci bir kişi yoktur. Yığın, ajanların günde birden fazla PR açtığı ya da reviewer'ın PR'ı açan kişiden farklı olduğu noktada kendini ödemeye başlar.

Ölçmek için iki sayı yeterli. Birincisi baseline dosyalarının satır sayısı: ajanlar devreye girdikten sonra düşmeye devam ediyor mu. İkincisi, mimari kontrolü yüzünden kırılan PR'ların kaçı insan müdahalesi olmadan, bir sonraki commit'te yeşile dönüyor. O oran yükseliyorsa hata mesajlarınız işini yapıyordur. Düşükse ya da PR'lar korkuluk diff'iyle yeşile dönüyorsa mesajları yeniden yazma zamanı gelmiştir.

Kontrol listesi

  • Talimat dosyasındaki her mimari kuralın yanında onu doğrulayan komut, bir cümlelik gerekçe ve doğru yapılmış bir örnek dosya var.
  • Talimat dosyasında build'in zorlamadığı mimari kural kalmadı; dosya kısa ve klasöre özel kurallar kapsamlı dosyalarda.
  • Ajan "bitti" demeden önce kontrol komutlarını çalıştırıyor; aynı komutlar CI'da her PR'da çalışıyor.
  • Mimari kontrollerin hata mesajları ne yapılacağını, örnek dosyayı ve yapılmaması gerekeni söylüyor.
  • public ve api bilinçli bir karar: iOS'ta InternalImportsByDefault, Android'de explicitApi() açık.
  • Yeni singleton, feature içinde doğrudan ağ istemcisi ve yasak import için lint ya da mimari testi var.
  • CI, yeni bastırma yorumlarını, büyüyen baseline'ı ve silinen ya da kapatılan testleri reddediyor.
  • Manifest, build mantığı, lint config'i, baseline'lar, kontrol script'leri, talimat dosyası ve CODEOWNERS'ın kendisi sahipli; code owner onayı zorunlu.
  • Ajanın yerel izinlerinde baseline ve lint config'i yazmaya kapalı, manifest onaya bağlı.
  • PR şablonu ajandan plan istiyor; review bağımlılık diff'inden başlıyor.
  • Bilerek yapılmış bir deneme (ajana feature'dan Networking import ettirecek bir görev) build'de kırılıyor ve ajan baseline'a dokunmadan düzeltiyor.
Seviye 21 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: AI ile test üretimi: kapsam artıyor, peki güven artıyor mu?

Bu konuda desteğe mi ihtiyacınız var?

Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.

Yorumlar ve sorular

Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.