Skip to content

Commit 2966db4

Browse files
committed
Scope mutation testing to local changes since the last commit and forbid full Stryker runs.
1 parent 7888a45 commit 2966db4

3 files changed

Lines changed: 61 additions & 4 deletions

File tree

.nuke/build.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@
110110
"type": "number",
111111
"description": "Minimum acceptable line coverage percentage for the Coverage gate",
112112
"format": "double"
113+
},
114+
"Since": {
115+
"type": "string",
116+
"description": "Git baseline that scopes mutation testing to your local changes. Defaults to 'HEAD' — only the code you have changed since your last commit. The Mutate target diffs against this with the git CLI and mutates just those files; running mutation across the whole codebase is intentionally not supported — it is far too slow"
113117
}
114118
}
115119
},

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
Full gate:
1717
1. **Full suite green.** Run the complete `.\build.ps1 --target Test` (not just the fixtures you touched) and paste the pass/fail totals. A single failure blocks everything.
1818
2. **Coverage ≥95% and not dropped.** Run `.\build.ps1 --target Coverage`, quote the exact merged line-coverage number. If it dropped versus the baseline — even while still ≥95% — that is a regression: add tests until it recovers, or state precisely why (e.g. pre-existing untested code in an unrelated assembly) with the measured baseline to prove it.
19-
3. **Mutation testing run and surviving mutants addressed.** Run `.\build.ps1 --target Mutate`. Stop the running Desktop app first (`Get-Process Collectary.UI.Desktop | Stop-Process -Force`) — a live instance locks `Collectary.UI.dll` and fails Stryker's build. Quote the mutation score and review survivors in the code you changed; kill them with tests or justify each explicitly.
19+
3. **Mutation testing — scoped to your local changes only, surviving mutants addressed. Running full Stryker is forbidden.** Stryker over the whole codebase takes far too long; never do it. Always run it scoped to your diff: `.\build.ps1 --target Mutate` `git diff`s against `HEAD` and mutates only those files, so it covers just the code you have changed since your last commit (your uncommitted working-tree changes). Run it **before you commit** — once your work is committed there is nothing left in the diff to mutate. Override the baseline only when you need a wider sweep (`.\build.ps1 --target Mutate --since <branch-or-commit>`). (`--since` is the git diff base, not Stryker's own `--since`, which LibGit2Sharp can't use in this relative-path worktree.) Stop the running Desktop app first (`Get-Process Collectary.UI.Desktop | Stop-Process -Force`) — a live instance locks `Collectary.UI.dll` and fails Stryker's build. Quote the mutation score and review survivors in the code you changed; kill them with tests or justify each explicitly.
2020
4. **Manual UI verification (for UI changes).** Ask the user to run the app with exact repro steps (see "Verifying UI Fixes"). Tests do not replace this; they are in addition to it.
2121

2222
If any gate cannot be completed (e.g. a pre-existing failure you did not introduce), STOP and surface it to the user with the evidence — do not quietly proceed as if it passed.
@@ -39,7 +39,7 @@ A feature or fix is complete **only** when every box below is genuinely ticked,
3939
- [ ] **All three layers present** (rule #4) — unit + integration + headless, or an explicit note on why a layer doesn't apply.
4040
- [ ] **Tests run, scaled to the change** (rule #5) — small localized change: relevant fixtures green, totals quoted, classification stated. Big/multi-project change: full suite green (`.\build.ps1 --target Test`), totals quoted.
4141
- [ ] **Coverage ≥95% and not dropped** (rule #5.2) — *big changes only*; exact number quoted; regressions explained with a measured baseline.
42-
- [ ] **Mutation run, survivors handled** (rule #5.3) — *big changes only*; Desktop app stopped first; score quoted; new survivors killed or justified.
42+
- [ ] **Mutation run scoped to local changes, survivors handled** (rule #5.3) — *big changes only*; run `.\build.ps1 --target Mutate` (diff vs `HEAD`, your uncommitted changes) **before** committing — never full Stryker; Desktop app stopped first; score quoted; new survivors killed or justified.
4343
- [ ] **Manual UI verification requested** (rule #5.4) — for any UI change, exact repro steps handed to the user.
4444
- [ ] **Docs updated** (rule #13).
4545
- [ ] **Localization complete** (rule #2) — every new key in both `Strings.en.resx` and `Strings.de.resx`.
@@ -57,7 +57,7 @@ dotnet build "src\Collectary.UI.Desktop\Collectary.UI.Desktop.csproj"
5757
5858
.\build.ps1 --target Test # all tests (default)
5959
.\build.ps1 --target Coverage # coverage gate ≥95%
60-
.\build.ps1 --target Mutate # mutation testing
60+
.\build.ps1 --target Mutate # mutation testing — scoped to your uncommitted changes since HEAD (full runs forbidden)
6161
dotnet test "tests\Collectary.UI.Tests\..." --filter "FullyQualifiedName~MethodName"
6262
dotnet ef migrations add <Name> --project src\Collectary.Infrastructure
6363
```

build/Build.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Globalization;
34
using System.Linq;
45
using System.Xml.Linq;
56
using Nuke.Common;
67
using Nuke.Common.IO;
78
using Nuke.Common.Tooling;
89
using Nuke.Common.Tools.DotNet;
10+
using Nuke.Common.Tools.Git;
911
using Serilog;
1012
using static Nuke.Common.Tools.DotNet.DotNetTasks;
13+
using static Nuke.Common.Tools.Git.GitTasks;
1114

1215
class Build : NukeBuild
1316
{
@@ -19,6 +22,9 @@ class Build : NukeBuild
1922
[Parameter("Minimum acceptable line coverage percentage for the Coverage gate")]
2023
readonly double CoverageThreshold = 95;
2124

25+
[Parameter("Git baseline that scopes mutation testing to your local changes. Defaults to 'HEAD' — only the code you have changed since your last commit. The Mutate target diffs against this with the git CLI and mutates just those files; running mutation across the whole codebase is intentionally not supported — it is far too slow.")]
26+
readonly string Since = "HEAD";
27+
2228
AbsolutePath TestProjectsRoot => RootDirectory / "tests";
2329
AbsolutePath CoverageDirectory => RootDirectory / "TestResults" / "coverage";
2430
AbsolutePath CoverageReportDirectory => RootDirectory / "TestResults" / "CoverageReport";
@@ -129,8 +135,55 @@ static double ReadLineRate(AbsolutePath coberturaFile)
129135
.DependsOn(Compile)
130136
.Executes(() =>
131137
{
138+
var changed = ChangedMutableSourceFiles();
139+
if (changed.Count == 0)
140+
{
141+
Log.Information("Mutate: no changed source files since {Since}; nothing to mutate.", Since);
142+
return;
143+
}
144+
132145
foreach (var project in new[] { "Collectary.Core", "Collectary.Infrastructure", "Collectary.Infrastructure.Cloud", "Collectary.Presentation" })
133-
DotNet($"stryker -p \"{RootDirectory / "src" / project / $"{project}.csproj"}\"",
146+
{
147+
var prefix = $"src/{project}/";
148+
var relative = changed
149+
.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
150+
.Select(f => f.Substring(prefix.Length))
151+
.ToArray();
152+
if (relative.Length == 0) continue;
153+
154+
var patterns = string.Join(" ", relative.Select(f => $"--mutate \"{f}\""));
155+
DotNet($"stryker -p \"{RootDirectory / "src" / project / $"{project}.csproj"}\" {patterns}",
134156
workingDirectory: RootDirectory);
157+
}
135158
});
159+
160+
IReadOnlyList<string> ChangedMutableSourceFiles()
161+
{
162+
IEnumerable<string> Lines(string arguments) =>
163+
Git(arguments, workingDirectory: RootDirectory, logOutput: false)
164+
.Where(o => o.Type == OutputType.Std)
165+
.Select(o => o.Text);
166+
167+
return Lines($"diff --name-only {Since}")
168+
.Concat(Lines("ls-files --others --exclude-standard"))
169+
.Select(p => p.Trim().Replace('\\', '/'))
170+
.Where(p => p.StartsWith("src/", StringComparison.OrdinalIgnoreCase)
171+
&& p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
172+
&& !IsExcludedFromMutation(p))
173+
.Distinct()
174+
.ToList();
175+
}
176+
177+
bool IsExcludedFromMutation(string path) =>
178+
path.EndsWith(".axaml.cs", StringComparison.OrdinalIgnoreCase)
179+
|| path.Contains("/Views/", StringComparison.OrdinalIgnoreCase)
180+
|| path.Contains("/Controls/", StringComparison.OrdinalIgnoreCase)
181+
|| path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase)
182+
|| path.Contains("/DI/", StringComparison.OrdinalIgnoreCase)
183+
|| path.EndsWith("/CloudModule.cs", StringComparison.OrdinalIgnoreCase)
184+
|| path.EndsWith("/InventoryDbContext.cs", StringComparison.OrdinalIgnoreCase)
185+
|| path.EndsWith("/MainWindowViewModel.cs", StringComparison.OrdinalIgnoreCase)
186+
|| path.EndsWith("/ThemeService.cs", StringComparison.OrdinalIgnoreCase)
187+
|| path.EndsWith("/AppLogger.cs", StringComparison.OrdinalIgnoreCase)
188+
|| path.EndsWith("/DialogService.cs", StringComparison.OrdinalIgnoreCase);
136189
}

0 commit comments

Comments
 (0)