Ruff zet 413 regels standaard aan. Wat betekent dat voor je .NET-analyzers?
Ruff v0.16.0 sprong van 59 naar 413 standaardregels. Een pleidooi voor strict-by-default linting, met een concrete aanpak voor Roslyn-analyzers, warnings-as-errors en CI in .NET.
Jean-Pierre Broeders
Freelance .NET Developer
Deze week stond Ruff v0.16.0 hoog op Hacker News. De reden: het aantal regels dat standaard aanstaat ging van 59 naar 413. Dat is de eerste keer sinds v0.1.0 dat het team de default set flink verbouwt. De discussie eronder liep meteen vol, want dit raakt een gevoelige snaar. Wie bepaalt wat "goede code" is, jij of je linter?
Ik werk zelf niet in Python maar in C#. Toch bleef dit nieuws hangen, want de vraag die eronder zit geldt precies zo voor .NET. Hoe streng zet je je statische analyse standaard, en op welk moment in de pijplijn laat je die pijn voelen? Ruff kiest nu voor streng-tenzij. De meeste .NET-projecten die ik binnenkom doen het omgekeerde: bijna alles staat uit, en niemand weet meer waarom.
Wat Ruff nou eigenlijk deed
De kern van de release is een filosofische keuze, geen technische. Astral, de makers van Ruff, redeneren dat veel regels echte fouten vangen. Syntaxproblemen, dingen die op runtime stukgaan, patronen die vrijwel altijd een bug zijn. Dat soort regels zou je moeten zien zonder dat je ze eerst met de hand aanzet. Dus zetten ze de default open.
Twaalf regels gingen van preview naar stabiel, waaronder airflow3-incompatible-function-signature (AIR303) en missing-copyright-notice (CPY001). Er kwam Markdown-formatting bij voor Python-codeblokken, nieuwe onderdruk-comments zoals ruff: ignore, en een --add-ignore vlag die die comments automatisch toevoegt. Handig, maar bijzaak.
Het echte verhaal is de default. En ze zijn eerlijk over de kosten: wil je het oude gedrag terug, dan zet je het in twee regels config recht.
# pyproject.toml
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
Dat is de oude wereld. Vier categorieën, 59 regels, verder je mond houden. De nieuwe wereld praat een stuk meer terug.
Waarom mij dit als .NET-er interesseert
Roslyn heeft sinds .NET 5 een hele batterij analyzers in de SDK zitten. CA-regels voor correctheid en veiligheid, IDE-regels voor stijl. Ze staan er allemaal in. Alleen: het gros staat op warning of zelfs op none, en een waarschuwing die niemand leest is geen waarschuwing. Het is ruis.
Ik zie dit patroon bij bijna elke opdracht. Een build met 340 warnings die iedereen wegscrollt. Ergens tussen die 340 zit een CA2100 (SQL-injectie-risico) of een CA5359 (certificaatvalidatie uitgezet). Niemand die het ziet, want de signaal-ruisverhouding is naar de knoppen. Ruff pakt dit probleem bij de wortel aan door de default streng te maken. In .NET moet je die keuze zelf nog maken, en dat is precies waar het misgaat.
Het goede nieuws: je hebt in .NET meer knoppen dan Ruff, en je kunt ze per project fijn afstellen. Het slechte nieuws: je moet ze zelf omzetten, en de default nodigt uit tot niks doen.
De .NET-versie van "streng tenzij"
Begin bij je project of, beter nog, bij een Directory.Build.props in de root zodat elke .csproj het erft.
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Even uitpakken wat hier gebeurt. AnalysisLevel met de suffix recommended zet een strengere set CA-regels aan dan de kale default. Je kunt kiezen uit minimum, default, recommended en all. Die laatste is meestal te veel van het goede, want dan krijg je ook regels die elkaar tegenspreken. recommended is voor de meeste teams de juiste plek om te beginnen.
EnforceCodeStyleInBuild trekt de IDE-stijlregels (de IDExxxx-familie) mee de build in. Standaard zie je die alleen in Visual Studio, niet op de commandline en dus ook niet in CI. Met deze vlag wel.
En dan TreatWarningsAsErrors. Dit is de knop die tanden geeft aan de rest. Zonder deze regel blijft alles een vrijblijvende suggestie. Met deze regel breekt je build. Dat voelt hard, en dat is ook de bedoeling.
Warnings-as-errors is geen alles-of-niks
De grootste angst die ik bij teams hoor: "als ik dat aanzet ontploft de build meteen." Klopt. En daarom zet je het genuanceerd aan in plaats van in één klap.
Je kunt per regel het gedrag sturen in .editorconfig. Dat bestand is trouwens veruit de fijnste plek om analyzer-config te beheren, want het reist met je code mee en werkt in de IDE en op de build hetzelfde.
# .editorconfig
root = true
[*.cs]
# Correctheid en security: hard falen
dotnet_diagnostic.CA2100.severity = error
dotnet_diagnostic.CA2007.severity = none
dotnet_analyzer_diagnostic.category-security.severity = error
# Stijl: waarschuwen, nog niet breken
dotnet_diagnostic.IDE0090.severity = warning
# Deze specifieke regel is voor ons team ruis
dotnet_diagnostic.CA1848.severity = suggestion
Zie je die category-security.severity = error? Daarmee zet je in één regel een hele categorie op fataal. Dat is de .NET-manier om te doen wat Ruff met zijn default doet: de dingen die echt fout zijn laat je de build breken, de rest houd je zichtbaar maar niet blokkerend. CA2007 heb ik hier op none gezet, want de ConfigureAwait-regel is in een applicatie (geen library) vaak alleen maar geblaat.
Belangrijk detail: bij TreatWarningsAsErrors wordt alles wat op warning staat een error. Wil je een paar warnings toch niet-fataal houden, dan gebruik je WarningsNotAsErrors.
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CA1848;CA1031</WarningsNotAsErrors>
</PropertyGroup>
Zo hou je de teugels strak zonder dat je op dag één door 200 meldingen moet ploegen.
De truc voor bestaande code: een baseline
Ruff zet zijn 413 regels aan op groene weide net zo makkelijk als op een codebase van tien jaar oud. In de praktijk is dat tweede geval waar het pijn doet. Voor .NET is er een nette uitweg die veel mensen niet kennen: warnings onderdrukken via een gegenereerd suppressiebestand, zodat nieuwe code streng is en oude code niet meteen de build sloopt.
De kortste variant werkt met dotnet format. Je draait de analyse, laat 'm fixen wat automatisch kan, en de rest zet je op een lijst.
# Wat kan automatisch weg
dotnet format analyzers --severity warn
# Wat overblijft, zien we op de build
dotnet build -c Release
Voor grotere codebases werkt een ratchet beter dan een big bang. Zet een handvol categorieën op error, fix die volledig, en breid daarna uit. Ik doe meestal security eerst, dan de correctheid-CA's, dan pas stijl. Stijl is het minst spannend en geeft de meeste weerstand in het team, dus die laat ik expres tot het laatst liggen.
En de buildtijd dan?
Een terechte tegenwerping die ook in de HN-draad langskwam: meer regels betekent meer werk voor de analyzer, en dus een tragere build. Bij Ruff valt dat mee, want het ding is in Rust geschreven en gaat als een speer. Bij Roslyn-analyzers is de impact echt merkbaar, zeker met een stapel externe pakketten zoals StyleCop, Roslynator en Meziantou erbovenop.
Twee dingen helpen. Ten eerste: zet analyzers niet aan in configuraties waar ze niks toevoegen. Een incrementele Debug-build op je eigen machine hoeft niet de volle set te draaien.
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild>
</PropertyGroup>
Zo blijft je binnenlus snel en draait de volledige analyse in Release en in CI, waar het ertoe doet. Ten tweede: meet voordat je klaagt. Met /p:ReportAnalyzer=true krijg je per analyzer te zien hoeveel tijd 'ie kost, en negen van de tien keer is er een enkele regel die de boel ophoudt. Die zet je dan gericht uit in plaats van de hele set af te schieten.
dotnet build -c Release /p:ReportAnalyzer=true
Laat CI de scheidsrechter zijn
Een linter die alleen op jouw machine draait bestaat niet voor de rest van het team. De regels moeten in de pijplijn staan, anders zijn ze een suggestie. Hier is een GitHub Actions-stap die zowel formatting als analyzers hard afdwingt.
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- name: Restore
run: dotnet restore
# Faalt als er iets niet geformatteerd is
- name: Check formatting
run: dotnet format --verify-no-changes --severity warn
# Faalt op elke analyzer-error dankzij TreatWarningsAsErrors
- name: Build
run: dotnet build -c Release --no-restore
dotnet format --verify-no-changes is het equivalent van Ruff die je diff laat zien voordat 'ie fixt. Het schrijft niks, het faalt alleen als er iets recht te trekken valt. Zet dit als aparte stap neer, niet verstopt in de build, want dan zie je in de PR-check in één oogopslag of het om stijl of om een echte fout gaat.
Waar Ruff wat mij betreft gelijk heeft
De discussie op Hacker News ging deels over autonomie. Sommigen vinden 413 regels bemoeizucht. Ik snap dat, maar ik denk dat de streng-tenzij-default de betere keuze is, en wel om een simpele reden. Een default die niks doet is ook een keuze, alleen een onzichtbare. Iemand betaalt de rekening later, meestal in de vorm van een bug die een aangezette regel had gevangen.
Wat Ruff goed doet is de ontsnappingsroute makkelijk maken. Twee regels config en je bent terug bij het oude gedrag. Die combinatie van streng-by-default met een lage drempel om lokaal af te wijken is precies wat .NET mist. Bij ons staat de default op passief, en de weg naar streng is een klus die niemand op zich neemt.
Wat ik je zou aanraden
Pak deze week een van je projecten en zet AnalysisLevel op latest-recommended met EnforceCodeStyleInBuild. Draai de build en kijk wat eruit komt. Waarschijnlijk schrik je van het aantal. Zet dan security en de harde CA-regels op error, de rest op warning, en laat CI de grens bewaken. Warnings-as-errors zet je erbij zodra de lijst schoon is, niet eerder.
Ruff heeft laten zien dat een tool best een mening mag hebben over je code. In .NET heb je diezelfde meningen al in de doos zitten. Je moet ze alleen nog aanzetten.
Bron: Ruff v0.16.0 op de Astral-blog en de discussie op Hacker News.
