Mert Özen Her Satırda Daha İleri
Test ve Kalite Statik Analiz — #01

Statik Analiz Nedir, Ne Bulur, Ne Bulamaz? Linter, Analyzer, Type Checker ve SAST Farkı

Mert Özen 14 Kas 2026 12 dk 10 görüntülenme
Statik Analiz Nedir, Ne Bulur, Ne Bulamaz? Linter, Analyzer, Type Checker ve SAST Farkı

Statik analiz kodu çalıştırmadan bug pattern'lerini ve kural ihlallerini yakalar, ama kodda yazılı olmayan business rule'ları göremez. Bookstore'da bir fiyat içe aktarma PR'ında .NET 10'un varsayılan ayarları 2 sorun, Recommended mod 7 diagnostic buldu; build temizlendikten sonra bile eksi fiyat ve 329,90'ı 32990 okuyan bir hata veritabanına kadar gitti. Yazı araç spektrumunu, severity'yi, IDE'den dashboard'a katmanları ve legacy projede ratchet stratejisini anlatıyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) 10.0.15 ve SQL Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) back office ekibi fiyatları tek tek elle değiştirmekten yorulmuş. Emre küçük bir PR (pull request; değişikliğin ana branch'e alınması için açılan inceleme isteği) açıyor: yönetim ekranından gelen düz metni satır satır okuyan ve kitap fiyatlarını toplu güncelleyen bir endpoint. Her satırda ISBN (kitapların uluslararası standart numarası) ve yeni fiyat noktalı virgülle ayrılmış:

using Bookstore.Data;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Catalog;

public sealed record PriceImportResult(int Updated, IReadOnlyList<string> UnknownIsbns);

public static class PriceImportModule
{
    public static IEndpointRouteBuilder MapPriceImportEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/admin/prices", ImportPrices).WithTags("Catalog");
        return app;
    }

    // Gövde: her satırda bir "isbn;price" çifti, ör. "9780441172719;459.90"
    private static async Task<Ok<PriceImportResult>> ImportPrices(
        HttpRequest request, BookstoreContext db, ILogger<PriceImportResult> logger, CancellationToken ct)
    {
        using StreamReader reader = new(request.Body);
        string body = await reader.ReadToEndAsync(ct);

        int updated = 0;
        List<string> unknown = [];
        foreach (string line in body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
        {
            string[] parts = line.Split(';');
            string isbn = parts[0];
            decimal price = decimal.Parse(parts[1]);

            Book? book = await db.Books
                .FromSqlRaw($"SELECT * FROM catalog.Books WHERE Isbn = '{isbn}'")
                .FirstOrDefaultAsync();
            if (book is null)
            {
                unknown.Add(isbn);
            }
            book.Price = price;
            updated++;
        }

        await db.SaveChangesAsync(ct);
        logger.LogInformation($"Price import finished: {updated} updated, {unknown.Count} unknown");
        return TypedResults.Ok(new PriceImportResult(updated, unknown));
    }
}

Bookstore'un Directory.Build.props dosyasında TreatWarningsAsErrors açık, analyzer ayarlarına ise hiç dokunulmamış; yani .NET 10 SDK'nın varsayılanları geçerli. Bu kodda kaç sorun görüyorsun? dotnet build bunlardan kaçını yakalar? Ve build tamamen temiz geçse bile production'a kaç hata gider?

Statik Analiz Ne Demek?

Statik analiz, kodu çalıştırmadan, yalnız kaynak koduna (ya da derlenmiş hâline) bakarak hakkında bir şey söyleyen her araçtır. Derleyicinin "bu değişken null olabilir" demesi de, ESLint'in "bu promise await edilmemiş" demesi de, SonarQube'un "bu method'un cognitive complexity'si 31" demesi de statik analizdir. Ortak noktaları, kodun hangi yollardan geçebileceğini tahmin etmeleri ve bunu bir test girdisine ihtiyaç duymadan yapmalarıdır. Testler ise dinamiktir: kodu belirli girdilerle çalıştırır ve yalnız o girdilerin yolunu görür.

"Statik analiz" tek bir araç değil, bir spektrum. Hangi sorunun hangi araçta yaşadığını bilmek, ekipte "bunu linter yakalamalıydı" tartışmasını erken bitirir:

TürSorduğu soruBookstore'da .NET tarafıBookstore'da Angular tarafı
Format ve styleKod ekibin yazım kurallarına uyuyor mu?.editorconfig, IDExxxx, dotnet formatPrettier, ESLint'in convention kuralları
Type checkerTipler tutarlı mı, null nereden sızabilir?C# derleyicisi, nullable reference types (CS86xx)TypeScript derleyicisi, strict
Correctness (bug pattern)Bu kod büyük ihtimalle yanlış mı çalışır?.NET analyzer'ları (CAxxxx), EF Core analyzer'ıtypescript-eslint, angular-eslint
MaintainabilityKod okunabilir ve değiştirilebilir mi?Complexity, duplication (SonarQube)Aynı metrikler (SonarQube)
Security (SAST)Kullanıcı girdisi tehlikeli bir çağrıya ulaşıyor mu?CodeQL, Semgrep, CA5xxxCodeQL, Semgrep

Bu seri tablonun ortasındaki üç satıra odaklanıyor: correctness, maintainability ve bunları üreten araçların ayarı. İlk satır, yani kod stilinin ve formatın araçla zorlanması, Coding Conventions ve Ekip Standartları serisinin konusu; özellikle "C# Convention'larını .editorconfig ile Kodlamak" yazısı IDExxxx kurallarını orada anlatıyor. Son satır, SAST (Static Application Security Testing; kaynak kodu çalıştırmadan tarayan güvenlik testi), DevSecOps serisinin "SAST: CodeQL, Semgrep ve .NET Security Analyzer'ları ile Kaynak Kodda Güvenlik Açığı Aramak" yazısında. Type checker satırını da ayrıca anlatmayacağız; nullable reference types C# İleri Seviye serisinin "C#'ta Nullable Reference Types Derinlemesine" yazısında var. Burada yalnız şunu bilmek yeterli: derleyici de bir statik analiz aracıdır ve çoğu ekibin ilk ve en ucuz analizi odur.

Bookstore PR'ı: Varsayılan Ayarlarla Ne Görünür?

Tahminin ilk cevabı. Hiçbir ayar değiştirmeden:

$ dotnet build src/Bookstore.Api
src/Bookstore.Api/Catalog/PriceImportModule.cs(33,18): error EF1002: Method 'FromSqlRaw' inserts interpolated strings directly into the SQL, without any protection against SQL injection. Consider using 'FromSql' instead, which protects against SQL injection, or make sure that the value is sanitized and suppress the warning.
src/Bookstore.Api/Catalog/PriceImportModule.cs(39,13): error CS8602: Dereference of a possibly null reference.
    0 Warning(s)
    2 Error(s)

İki hata, ikisi de warning olarak üretilip TreatWarningsAsErrors yüzünden error'a dönüşmüş. Biri derleyicinin nullable analizinden geliyor: book is null dalında continue unutulmuş, kod bilinmeyen ISBN'de NullReferenceException fırlatacak. Diğeri SDK'dan değil, EF Core paketinin içinde gelen bir analyzer'dan: FromSqlRaw'a interpolated string verilmiş, bu da klasik bir SQL injection.

Ama kodda daha fazlası var. .NET 10 SDK'sının varsayılan analysis mode'u yalnız küçük bir kural setini build warning'i olarak açıyor; geri kalan kuralların bir kısmı IDE'de yalnız suggestion (üç noktalı alt çizgi) olarak görünüyor, çoğu hiç çalışmıyor. Aynı kodu Microsoft'un önerdiği daha geniş setle derleyelim (bir sonraki yazının konusu):

$ dotnet build src/Bookstore.Api -p:AnalysisMode=Recommended -p:TreatWarningsAsErrors=false
PriceImportModule.cs(30,29): warning CA1305: The behavior of 'decimal.Parse(string)' could vary based on the current user's locale settings. Replace this call in 'PriceImportModule.ImportPrices(HttpRequest, BookstoreContext, ILogger<PriceImportResult>, CancellationToken)' with a call to 'decimal.Parse(string, IFormatProvider)'.
PriceImportModule.cs(32,32): warning CA2016: Forward the 'ct' parameter to the 'FirstOrDefaultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token
PriceImportModule.cs(33,18): warning EF1002: Method 'FromSqlRaw' inserts interpolated strings directly into the SQL, without any protection against SQL injection. ...
PriceImportModule.cs(39,13): warning CS8602: Dereference of a possibly null reference.
PriceImportModule.cs(44,31): warning CA1873: Evaluation of this argument may be expensive and unnecessary if logging is disabled
PriceImportModule.cs(44,31): warning CA2254: The logging message template should not vary between calls to 'LoggerExtensions.LogInformation(ILogger, string?, params object?[])'
PriceImportModule.cs(44,9): warning CA1848: For improved performance, use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogInformation(ILogger, string?, params object?[])'

Yedi diagnostic, beş ayrı sorun. CA2016, iptal edilen bir isteğin veritabanı sorgusunu durduramayacağını söylüyor. Son üç satırın hepsi aynı log satırı için: interpolated string her çağrıda farklı bir mesaj template'i üretiyor, structured logging'deki {Updated} gibi alanlar kayboluyor ve log seviyesi kapalı olsa bile string her seferinde oluşturuluyor. En ilginci CA1305. Kural "kültüre bağlı davranış" diyor, ama Türkiye'de çalışan bir ekip için bu soyut bir uyarı değil:

en-US: decimal.Parse("459.90") =     459.90
tr-TR: decimal.Parse("459.90") =   45990,00
de-DE: decimal.Parse("459.90") =   45990,00

Sunucunun kültürü tr-TR ise Dune'un fiyatı 459,90 TRY (Türk lirası) yerine 45.990 TRY olur. Varsayılan ayarlarla bu satır hiçbir uyarı üretmiyordu.

Ne Bulamaz?

Emre yedi diagnostic'i düzeltiyor: decimal.Parse(parts[1], CultureInfo.InvariantCulture), LINQ (Language Integrated Query; C#'ın sorgu syntax'ı) ile FirstOrDefaultAsync(b => b.Isbn == isbn, ct), continue, mesaj template'li log. Build temiz. Uygulamayı kendi SQL Server container'ına karşı çalıştırıp back office'in gerçekten gönderebileceği bir dosya yolluyoruz:

$ cat prices.txt
9780441172719;459.90
9780141439518;-45.00
9780261102217;329,90
9780000000000;99.00
$ curl -X POST --data-binary @prices.txt -H "Content-Type: text/plain" http://localhost:5080/api/admin/prices
{"updated":3,"unknownIsbns":["9780000000000"]}
HTTP 200
Dune 459.9
Pride and Prejudice -45.0
The Hobbit 32990.0

Pride and Prejudice artık eksi 45 TRY. Bu bir business rule ihlali: fiyat sıfırdan büyük olmalı. Hiçbir analyzer bunu bilemez, çünkü kural kodda yazılı değil, Selin'in kafasında ve story'de yazılı. The Hobbit ise 32.990 TRY oldu: Excel'den Türkçe ayarlarla dışarı aktarılmış "329,90" değeri, InvariantCulture'da virgül binlik ayırıcı sayıldığı için 32990 okundu. CA1305'i düzeltmek bizi bir karar vermeye zorladı, ama kararın doğru olup olmadığını söyleyemez. Dosya formatının sözleşmesi (nokta mı virgül mü, hatalı satır ne olur) bir requirement'tır.

Bunun teorik bir sebebi de var. Rice teoremine göre bir programın davranışıyla ilgili önemsiz olmayan her soru genel durumda karar verilemez bir problemdir. Pratikteki karşılığı şu: her statik analiz aracı ya bazı gerçek hataları kaçırır (false negative) ya da bazı doğru kodları hatalı sanar (false positive), çoğu ikisini birden yapar. Araçlar bu dengeyi farklı kurar. Derleyicinin nullable analizi bazı yolları göremediği için null! gibi kaçış kapıları bırakır; bir güvenlik aracı ise kaçırmamak için daha çok false positive üretmeyi tercih eder. Statik analiz testlerin, code review'un ve production'daki gözlemin yerini tutmaz; onların önündeki ucuz filtredir. Code Review serisinin ilk yazısındaki ayrım burada da geçerli: tekrarlayan, mekanik kontrolleri araca bırak, insan dikkatini tasarıma ve davranışa harca.

False Positive, Severity ve Uyarının Maliyeti

Her uyarının bir maliyeti var: okunması, anlaşılması, düzeltilmesi ya da bastırılması gerekir. Bir kural ekibe değerinden fazla iş çıkarırsa insanlar uyarıları okumayı bırakır ve gerçek bulgu da kaybolur. Bunu ölçmek için Bookstore'un referans uygulamasını, PR'dan önceki hâliyle, üç farklı analysis mode'da derledik:

$ dotnet build Bookstore.slnx --no-incremental -p:AnalysisMode=Default -p:TreatWarningsAsErrors=false
    0 Warning(s)
$ dotnet build Bookstore.slnx --no-incremental -p:AnalysisMode=Recommended -p:TreatWarningsAsErrors=false
    13 Warning(s)
$ dotnet build Bookstore.slnx --no-incremental -p:AnalysisMode=All -p:TreatWarningsAsErrors=false
    126 Warning(s)

All modundaki 126 uyarının dağılımı öğretici. 40'ı CA2007 (await edilen task'ta ConfigureAwait çağır); ASP.NET Core'da SynchronizationContext olmadığı için bu kural uygulama kodunda değer üretmez. 39'u CA1515 (public type'ları internal yap); bir library için anlamlı, ama Minimal API endpoint'lerinin ve test projesinin type'ları için tartışmalı. 8'i CA1707: test method adlarındaki alt çizgiler; ekibin bilinçli bir convention'ı. Geriye tartışmaya değer birkaç bulgu kalıyor; örneğin Program.cs'teki app.Run() için async karşılığını öneren CA1849 ve müşteri e-postasını normalize ederken ToLowerInvariant yerine ToUpperInvariant öneren CA1308. Yani "hepsini aç" demek, 126 satırlık bir listenin içinde birkaç değerli satırı saklamak demek.

Severity bu dengeyi kurmanın aracı. Roslyn analyzer'larında her kural error, warning, suggestion, silent ya da none seviyesine ayarlanabilir; suggestion yalnız IDE'de görünür, build'i etkilemez. ESLint'te karşılığı error, warn, off. İyi bir kural seti iki soruyu cevaplar: bu kural bizim kodumuzda gerçek bir hatayı ne sıklıkla yakalıyor, ve false positive verdiğinde onu bastırmak ne kadar zahmetli? CA2016 ve CA1305 Bookstore'da bu iki soruyu iyi cevaplıyor. CA2007 cevaplamıyor.

Nerede Çalışır: IDE'den Dashboard'a

Aynı kural farklı yerlerde çalışabilir ve her yerin geri bildirim süresi, kitlesi ve zorlama gücü farklı.

01-analysis-layers-tr.png

IDE en hızlı katman. Visual Studio, Rider ya da Visual Studio Code'daki C# Dev Kit, Roslyn analyzer'larını yazarken çalıştırır; varsayılan modda CA2016 gibi suggestion seviyesindeki kurallar burada görünür ama build'de görünmez. Sorun şu ki IDE'deki uyarı yalnız o anda dosyaya bakan kişiye görünür ve kimseyi durdurmaz. Pre-commit hook yalnız değişen dosyaları hızlıca kontrol eder; ama --no-verify ile atlanabilir, bu yüzden bir kapı değildir. Bunu Coding Conventions serisinin "Kuralları Otomatikleştirmek" yazısında ayrıntılı gördük.

Build katmanı kuralların zorlandığı ilk yer. Bookstore'da TreatWarningsAsErrors açık olduğu için yukarıdaki iki bulgu build'i kırdı; IDE'de ve CI'da aynı sonuç çıkar. CI (pull request'te) aynı build'i herkes için çalıştırır ve sonuçları SARIF (Static Analysis Results Interchange Format; analiz sonuçları için standart JSON formatı) olarak dışarı verirse GitHub bunları PR'daki satırların yanında annotation olarak gösterebilir. Dashboard katmanı ise SonarQube gibi bir sunucudur: tek bir PR'a değil, zamana bakar. Hangi module'de teknik borç artıyor, yeni kodda kaç issue açıldı, coverage nereye gidiyor. Bookstore PR'ı bu katmanda tek tek satırlar olarak değil, yeni kodda açılan issue'ların sayısı ve quality gate'in geçip geçmediği olarak görünür; kurulumunu ve gate ayarını beşinci yazıda yapacağız.

Pratik kural: bir bulgu ne kadar sağda yakalanırsa, onu düzeltecek kişi o kadar bağlamdan kopmuş olur. Bu yüzden aynı kural seti IDE'de ve CI'da birebir aynı olmalı; geliştiricinin makinesinde temiz geçen kodun CI'da kırılması, ekibin araca güvenini en hızlı kaybettiren şeydir. .NET tarafında bunu kuralları proje dosyalarına ve repo'daki config dosyalarına koyarak sağlarız; makinedeki IDE ayarına değil.

Legacy Projeye Eklemek: Baseline ve Ratchet

Bookstore'un referans uygulaması küçük ve temiz. Gerçek hayatta statik analiz çoğunlukla on yaşında, 400 bin satırlık bir solution'a eklenir ve ilk build binlerce uyarı verir. Bu noktada ekiplerin üç tepkisi olur. Birincisi her şeyi bir sprint'te temizlemeye çalışmak: büyük, riskli, review edilemez PR'lar ve davranış değiştiren "düzeltmeler". İkincisi uyarıları toptan kapatmak: NoWarn'a uzun bir liste ya da dotnet_analyzer_diagnostic.severity = none. Bu durumda yeni kod da aynı hataları serbestçe yapmaya devam eder. Üçüncüsü, işe yarayan yol, ratchet'tir (yalnız tek yöne dönen mandal).

Ratchet'in fikri basit: bugünkü durumu bir baseline olarak kaydet, yeni kodda sıfır tolerans uygula, eskiyi kademeli temizle ve temizlenen yeri bir daha kirletme. Uygulamanın üç yolu var. Kural bazında: önce gerçekten bug yakalayan birkaç kuralı (CA2016, CA1305 gibi) bütün solution'da warning yap, ihlalleri temizle, sonra bir sonraki kurala geç. Yol bazında: .editorconfig ile yeni module'lerin klasörlerinde daha sıkı, eski klasörlerde daha gevşek severity ver. Diff bazında: araç bütün kodu analiz eder ama yalnız değişen satırlardaki yeni bulgular PR'ı kırar. SonarQube'un "new code" kavramı tam olarak bunu yapar; .NET tarafında ise SARIF çıktısını bir baseline dosyasıyla karşılaştırarak aynı etkiyi elde edebilirsin. İkinci yazıda kural bazlı yolu, beşinci yazıda diff bazlı yolu Bookstore üzerinde uygulayacağız.

Hangisini seçersen seç, baseline'ın bir sahibi ve bir trendi olmalı. "Eski uyarı sayısı her sprint düşüyor mu?" sorusunun cevabı yoksa baseline sessizce kalıcı hâle gelir.

Üretimde Dikkat

Analiz süresini ölç. Analyzer'lar derleyiciyle birlikte çalışır ve büyük solution'larda bazıları build süresini belirgin şekilde uzatabilir. Üçüncü yazıda -p:ReportAnalyzer=true ile hangi analyzer'ın ne kadar sürdüğünü ölçeceğiz. TypeScript tarafında type-aware lint kuralları da aynı soruyu sorar; dördüncü yazının konusu.

SDK yükseltmesini bir kural değişikliği olarak gör. AnalysisLevel'ın varsayılanı latest; yani .NET SDK'yı yükselttiğinde yeni kurallar kendiliğinden açılır ve TreatWarningsAsErrors açık bir projede CI'ı kırar. Seviyeyi sabitlemek ve yükseltmeyi ayrı bir PR'da yapmak ikinci yazıda.

Sonucu herkes aynı yerde görsün. Uyarıların yalnız build log'unda kalması, kimsenin okumaması demektir. PR annotation'ı ya da dashboard olmadan statik analiz, ekip için görünmez bir kontrol olur.

Sık Düşülen Tuzaklar

Varsayılanların yeterli olduğunu sanmak. .NET 10'da varsayılan mod, Bookstore PR'ındaki yedi diagnostic'in beşini göstermedi. "Analyzer'lar zaten açık" cümlesi doğru ama yanıltıcı: açık olan küçük bir set.

Her şeyi açıp warning olarak bırakmak. 126 uyarılık bir build log'u kimse okumaz. Ya bir kural gerçekten build'i kırmaya değer, ya da kapalıdır; arada kalan yüzlerce warning, yeni ve önemli bulguyu gizler.

Uyarıyı kodu değiştirmeden susturmak. #pragma warning disable ya da gerekçesiz SuppressMessage bulguyu yok etmez, sadece görünmez yapar. Bastırma bir karardır ve gerekçesiyle yazılmalıdır; nasıl yapılacağı ikinci yazıda.

Statik analizi test yerine koymak. Bookstore PR'ı build'den temiz çıktıktan sonra eksi fiyatlı bir kitap ve 32.990 TRY'lik bir The Hobbit üretti. Bu iki hatayı bir integration test ya da reviewer yakalar; analyzer yakalamaz.

Küçük Bir Deneme

★☆☆ Kendi projende dotnet build -p:AnalysisMode=Recommended -p:TreatWarningsAsErrors=false çalıştır ve uyarıları kural ID'sine göre say (grep -o "warning CA[0-9]*" | sort | uniq -c). İlk üç kuralın dokümanını oku: bunlar senin kodunda gerçek bir hatayı mı işaret ediyor, yoksa bir tercih mi?

★★☆ Bookstore PR'ındaki decimal.Parse satırını CultureInfo.InvariantCulture ile düzelt, sonra "329,90" girdisini reddeden bir doğrulama yaz: hatalı satır endpoint'in cevabında listelensin. Bu doğrulamayı hangi araç zorlayabilir, hangisi zorlayamaz?

★★★ Ekibinin en büyük solution'ında All modda bir build al ve uyarıları "gerçek bug", "bizim convention'ımıza aykırı" ve "bizim için anlamsız" diye üç gruba ayır. Her grup için bir kural listesi çıkar. Bu liste bir sonraki yazının .globalconfig dosyasının ilk taslağı olacak.

Bu yazıda statik analizin neyi bulduğunu, neyi bulamadığını ve nerede çalıştığını gördük. Sıradaki yazıda .NET analyzer'larını gerçekten yöneteceğiz: AnalysisLevel ve AnalysisMode değerleri, .globalconfig ile .editorconfig farkı, hedefli WarningsAsErrors, bastırma kuralları ve Bookstore için seçilmiş kurallar.