From cc3bb28acab31bcff52a24a8eb130d00fc71afb4 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 20:31:01 +0200 Subject: [PATCH 1/6] Initial commit --- MutationCase.fs | 13 ++ README.md | 7 + verify-coverage-mutants.fsx | 246 ++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 MutationCase.fs create mode 100644 README.md create mode 100644 verify-coverage-mutants.fsx diff --git a/MutationCase.fs b/MutationCase.fs new file mode 100644 index 0000000..33891ef --- /dev/null +++ b/MutationCase.fs @@ -0,0 +1,13 @@ +namespace Mutation + +open System + +[] +type MutationCaseAttribute(id: string, file: string, line: int, find: string, replace: string) = + inherit Attribute() + + member _.Id = id + member _.File = file + member _.Line = line + member _.Find = find + member _.Replace = replace diff --git a/README.md b/README.md new file mode 100644 index 0000000..493143e --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# mutannot + +This allows you to annotate Xunit test cases with a mutation that should cause the test to fail. + +`verify-coverage-mutants.fsx` will apply each mutation and verify that the test actually fails. + +Current state: LLM-generated prototype diff --git a/verify-coverage-mutants.fsx b/verify-coverage-mutants.fsx new file mode 100644 index 0000000..25c43b4 --- /dev/null +++ b/verify-coverage-mutants.fsx @@ -0,0 +1,246 @@ +open System +open System.IO +open System.Reflection +open System.Diagnostics + +type MutationCase = + { Id: string + File: string + Line: int + Find: string + Replace: string + TestName: string + DeclaringType: string } + +type Command = + | List + | Show of string + | Run of string list + +type Options = + { Configuration: string + NoBuild: bool + Command: Command } + +let projectPath = "Example.Tests" +let targetFramework = "net10.0" +let projectDirectory = "example-tests" +let testProjectPath = Path.Combine(projectDirectory, "Example.Tests", "Example.Tests.fsproj") + +let fail message = + eprintfn "%s" message + Environment.Exit 1 + Unchecked.defaultof<_> + +let runProcess (workingDirectory: string) (exe: string) (args: string list) = + let psi = ProcessStartInfo() + psi.FileName <- exe + psi.WorkingDirectory <- workingDirectory + psi.RedirectStandardOutput <- false + psi.RedirectStandardError <- false + psi.UseShellExecute <- false + for arg in args do + psi.ArgumentList.Add arg + use proc = new Process() + proc.StartInfo <- psi + if not (proc.Start()) then + failwithf "Failed to start %s" exe + proc.WaitForExit() + proc.ExitCode + +let captureProcess (workingDirectory: string) (exe: string) (args: string list) = + let psi = ProcessStartInfo() + psi.FileName <- exe + psi.WorkingDirectory <- workingDirectory + psi.RedirectStandardOutput <- true + psi.RedirectStandardError <- true + psi.UseShellExecute <- false + for arg in args do + psi.ArgumentList.Add arg + use proc = new Process() + proc.StartInfo <- psi + if not (proc.Start()) then + failwithf "Failed to start %s" exe + let stdout = proc.StandardOutput.ReadToEnd() + let stderr = proc.StandardError.ReadToEnd() + proc.WaitForExit() + proc.ExitCode, stdout, stderr + +let repoRoot = + let exitCode, stdout, stderr = captureProcess Environment.CurrentDirectory "git" [ "rev-parse"; "--show-toplevel" ] + if exitCode <> 0 then fail stderr + stdout.Trim() + +let makeRelativePath (path: string) = + if Path.IsPathRooted path then Path.GetRelativePath(repoRoot, path) else path + +let parseArgs (args: string list) = + let rec loop configuration noBuild remaining = + match remaining with + | [] -> { Configuration = configuration; NoBuild = noBuild; Command = Run [] } + | "--configuration" :: value :: tail -> loop value noBuild tail + | "--no-build" :: tail -> loop configuration true tail + | "--list" :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = List } + | "--show" :: id :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = Show id } + | "--run" :: tail -> { Configuration = configuration; NoBuild = noBuild; Command = Run tail } + | value :: tail when not (value.StartsWith "--") -> { Configuration = configuration; NoBuild = noBuild; Command = Run (value :: tail) } + | _ -> fail "Usage: dotnet fsi verify-coverage-mutants.fsx [--configuration Debug|Release] [--no-build] [--list | --show | --run [id...]]" + loop "Debug" false args + +let options = parseArgs (fsi.CommandLineArgs |> Array.skip 1 |> Array.toList) + +let assemblyPath = + Path.Combine(repoRoot, projectDirectory, "Example.Tests", "bin", options.Configuration, targetFramework, "Example.Tests.dll") + +let ensureBuilt () = + if not options.NoBuild then + let exitCode = runProcess repoRoot "dotnet" [ "build"; testProjectPath; "--configuration"; options.Configuration; "--nologo" ] + if exitCode <> 0 then fail "dotnet build failed." + if not (File.Exists assemblyPath) then + fail $"Compiled test assembly not found at {assemblyPath}." + +let installAssemblyResolver () = + let assemblyDir = Path.GetDirectoryName assemblyPath + AppDomain.CurrentDomain.add_AssemblyResolve(ResolveEventHandler(fun _ args -> + let name = AssemblyName(args.Name).Name + ".dll" + let candidate = Path.Combine(assemblyDir, name) + if File.Exists candidate then Assembly.LoadFrom candidate else null)) + +let mutationCases () = + ensureBuilt () + installAssemblyResolver () + let asm = Assembly.LoadFrom assemblyPath + asm.GetTypes() + |> Array.collect (fun t -> + t.GetMethods(BindingFlags.Public ||| BindingFlags.NonPublic ||| BindingFlags.Static ||| BindingFlags.Instance) + |> Array.collect (fun m -> + m.GetCustomAttributesData() + |> Seq.filter (fun attr -> attr.AttributeType.FullName = "FsCheck.Test.MutationCaseAttribute") + |> Seq.map (fun attr -> + let args = attr.ConstructorArguments + if args.Count <> 5 then failwithf "Unexpected MutationCaseAttribute shape on %s.%s" t.FullName m.Name + { Id = unbox args[0].Value + File = unbox args[1].Value + Line = unbox args[2].Value + Find = unbox args[3].Value + Replace = unbox args[4].Value + TestName = m.Name + DeclaringType = t.FullName }) + |> Seq.toArray)) + |> Array.sortBy (fun mutation -> mutation.Id) + +let findMutation id mutations = + mutations + |> Array.tryFind (fun mutation -> mutation.Id = id) + |> Option.defaultWith (fun () -> fail $"Unknown mutation id: {id}") + +let lineNumberAt (text: string) index = + let mutable line = 1 + for i in 0 .. index - 1 do + if text[i] = '\n' then + line <- line + 1 + line + +let replaceNearestOccurrence (mutation: MutationCase) (text: string) = + let rec collect fromIndex acc = + let idx = text.IndexOf(mutation.Find, fromIndex, StringComparison.Ordinal) + if idx < 0 then List.rev acc + else collect (idx + 1) (idx :: acc) + + let matches = collect 0 [] + match matches with + | [] -> fail $"Could not find '{mutation.Find}' in {mutation.File}" + | _ -> + let chosen = + matches + |> List.minBy (fun idx -> abs (lineNumberAt text idx - mutation.Line)) + text.Remove(chosen, mutation.Find.Length).Insert(chosen, mutation.Replace) + +let printMutation mutation = + printfn "id: %s" mutation.Id + printfn "test: %s.%s" mutation.DeclaringType mutation.TestName + printfn "file: %s:%d" mutation.File mutation.Line + printfn "find: %s" mutation.Find + printfn "replace: %s" mutation.Replace + +let tempRoot = Path.Combine(Path.GetTempPath(), $"fscheck-mutants.{Guid.NewGuid():N}") +let worktreePath = Path.Combine(tempRoot, "worktree") +let patchPath = Path.Combine(tempRoot, "current.patch") + +let cleanup () = + if Directory.Exists worktreePath then + let _ = captureProcess repoRoot "git" [ "worktree"; "remove"; "--force"; worktreePath ] + () + if Directory.Exists tempRoot then + Directory.Delete(tempRoot, true) + +let createWorktree () = + Directory.CreateDirectory(tempRoot) |> ignore + let exitCode, diffText, stderr = captureProcess repoRoot "git" [ "diff"; "--binary"; "HEAD" ] + if exitCode <> 0 then fail stderr + File.WriteAllText(patchPath, diffText) + + let exitCode2, stdout2, stderr2 = captureProcess repoRoot "git" [ "rev-parse"; "HEAD" ] + if exitCode2 <> 0 then fail stderr2 + let baseRev = stdout2.Trim() + + let exitCode3, _, stderr3 = captureProcess repoRoot "git" [ "worktree"; "add"; "--detach"; worktreePath; baseRev ] + if exitCode3 <> 0 then fail stderr3 + + if FileInfo(patchPath).Length > 0L then + let exitCode4, _, stderr4 = captureProcess worktreePath "git" [ "apply"; patchPath ] + if exitCode4 <> 0 then fail stderr4 + +let testFilter mutation = $"FullyQualifiedName~{mutation.TestName}" + +let runMutation (mutation: MutationCase) = + let targetFile = Path.Combine(worktreePath, mutation.File.Replace('/', Path.DirectorySeparatorChar)) + if not (File.Exists targetFile) then + fail $"Target file does not exist in worktree: {targetFile}" + + let originalText = File.ReadAllText targetFile + let mutatedText = replaceNearestOccurrence mutation originalText + File.WriteAllText(targetFile, mutatedText) + + printfn "==> %s: %s" mutation.Id mutation.TestName + let exitCode = runProcess worktreePath "dotnet" [ "test"; testProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--nologo" ] + File.WriteAllText(targetFile, originalText) + + if exitCode = 0 then + printfn "SURVIVED %s" mutation.Id + false + else + printfn "KILLED %s" mutation.Id + true + +let mutations = mutationCases () + +if Array.isEmpty mutations then + fail "No mutation cases were discovered in the test assembly." + +match options.Command with +| List -> + mutations + |> Array.iter (fun mutation -> + printfn "%s\t%s\t%s:%d" mutation.Id mutation.TestName mutation.File mutation.Line) +| Show id -> + let mutation = findMutation id mutations + printMutation mutation +| Run ids -> + let requested = + match ids with + | [] -> mutations |> Array.toList + | _ -> ids |> List.map (fun id -> findMutation id mutations) + + try + createWorktree () + let killed = + requested + |> List.map runMutation + let survivors = killed |> List.filter not |> List.length + if survivors = 0 then + printfn "All requested mutants were killed." + else + fail $"{survivors} mutant(s) survived." + finally + cleanup () From c2e96604c6628830de7c1c4f6a5ff028071ab802 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 20:31:01 +0200 Subject: [PATCH 2/6] Initial commit --- MutationCase.fs | 13 ++ README.md | 7 + verify-coverage-mutants.fsx | 246 ++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 MutationCase.fs create mode 100644 README.md create mode 100644 verify-coverage-mutants.fsx diff --git a/MutationCase.fs b/MutationCase.fs new file mode 100644 index 0000000..12e6475 --- /dev/null +++ b/MutationCase.fs @@ -0,0 +1,13 @@ +namespace Mutannot + +open System + +[] +type MutationCaseAttribute(id: string, file: string, line: int, find: string, replace: string) = + inherit Attribute() + + member _.Id = id + member _.File = file + member _.Line = line + member _.Find = find + member _.Replace = replace diff --git a/README.md b/README.md new file mode 100644 index 0000000..493143e --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# mutannot + +This allows you to annotate Xunit test cases with a mutation that should cause the test to fail. + +`verify-coverage-mutants.fsx` will apply each mutation and verify that the test actually fails. + +Current state: LLM-generated prototype diff --git a/verify-coverage-mutants.fsx b/verify-coverage-mutants.fsx new file mode 100644 index 0000000..08212ef --- /dev/null +++ b/verify-coverage-mutants.fsx @@ -0,0 +1,246 @@ +open System +open System.IO +open System.Reflection +open System.Diagnostics + +type MutationCase = + { Id: string + File: string + Line: int + Find: string + Replace: string + TestName: string + DeclaringType: string } + +type Command = + | List + | Show of string + | Run of string list + +type Options = + { Configuration: string + NoBuild: bool + Command: Command } + +let projectPath = "Example.Tests" +let targetFramework = "net10.0" +let projectDirectory = "example-tests" +let testProjectPath = Path.Combine(projectDirectory, "Example.Tests", "Example.Tests.fsproj") + +let fail message = + eprintfn "%s" message + Environment.Exit 1 + Unchecked.defaultof<_> + +let runProcess (workingDirectory: string) (exe: string) (args: string list) = + let psi = ProcessStartInfo() + psi.FileName <- exe + psi.WorkingDirectory <- workingDirectory + psi.RedirectStandardOutput <- false + psi.RedirectStandardError <- false + psi.UseShellExecute <- false + for arg in args do + psi.ArgumentList.Add arg + use proc = new Process() + proc.StartInfo <- psi + if not (proc.Start()) then + failwithf "Failed to start %s" exe + proc.WaitForExit() + proc.ExitCode + +let captureProcess (workingDirectory: string) (exe: string) (args: string list) = + let psi = ProcessStartInfo() + psi.FileName <- exe + psi.WorkingDirectory <- workingDirectory + psi.RedirectStandardOutput <- true + psi.RedirectStandardError <- true + psi.UseShellExecute <- false + for arg in args do + psi.ArgumentList.Add arg + use proc = new Process() + proc.StartInfo <- psi + if not (proc.Start()) then + failwithf "Failed to start %s" exe + let stdout = proc.StandardOutput.ReadToEnd() + let stderr = proc.StandardError.ReadToEnd() + proc.WaitForExit() + proc.ExitCode, stdout, stderr + +let repoRoot = + let exitCode, stdout, stderr = captureProcess Environment.CurrentDirectory "git" [ "rev-parse"; "--show-toplevel" ] + if exitCode <> 0 then fail stderr + stdout.Trim() + +let makeRelativePath (path: string) = + if Path.IsPathRooted path then Path.GetRelativePath(repoRoot, path) else path + +let parseArgs (args: string list) = + let rec loop configuration noBuild remaining = + match remaining with + | [] -> { Configuration = configuration; NoBuild = noBuild; Command = Run [] } + | "--configuration" :: value :: tail -> loop value noBuild tail + | "--no-build" :: tail -> loop configuration true tail + | "--list" :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = List } + | "--show" :: id :: tail when tail.IsEmpty -> { Configuration = configuration; NoBuild = noBuild; Command = Show id } + | "--run" :: tail -> { Configuration = configuration; NoBuild = noBuild; Command = Run tail } + | value :: tail when not (value.StartsWith "--") -> { Configuration = configuration; NoBuild = noBuild; Command = Run (value :: tail) } + | _ -> fail "Usage: dotnet fsi verify-coverage-mutants.fsx [--configuration Debug|Release] [--no-build] [--list | --show | --run [id...]]" + loop "Debug" false args + +let options = parseArgs (fsi.CommandLineArgs |> Array.skip 1 |> Array.toList) + +let assemblyPath = + Path.Combine(repoRoot, projectDirectory, "Example.Tests", "bin", options.Configuration, targetFramework, "Example.Tests.dll") + +let ensureBuilt () = + if not options.NoBuild then + let exitCode = runProcess repoRoot "dotnet" [ "build"; testProjectPath; "--configuration"; options.Configuration; "--nologo" ] + if exitCode <> 0 then fail "dotnet build failed." + if not (File.Exists assemblyPath) then + fail $"Compiled test assembly not found at {assemblyPath}." + +let installAssemblyResolver () = + let assemblyDir = Path.GetDirectoryName assemblyPath + AppDomain.CurrentDomain.add_AssemblyResolve(ResolveEventHandler(fun _ args -> + let name = AssemblyName(args.Name).Name + ".dll" + let candidate = Path.Combine(assemblyDir, name) + if File.Exists candidate then Assembly.LoadFrom candidate else null)) + +let mutationCases () = + ensureBuilt () + installAssemblyResolver () + let asm = Assembly.LoadFrom assemblyPath + asm.GetTypes() + |> Array.collect (fun t -> + t.GetMethods(BindingFlags.Public ||| BindingFlags.NonPublic ||| BindingFlags.Static ||| BindingFlags.Instance) + |> Array.collect (fun m -> + m.GetCustomAttributesData() + |> Seq.filter (fun attr -> attr.AttributeType.FullName = "Mutannot.MutationCaseAttribute") + |> Seq.map (fun attr -> + let args = attr.ConstructorArguments + if args.Count <> 5 then failwithf "Unexpected MutationCaseAttribute shape on %s.%s" t.FullName m.Name + { Id = unbox args[0].Value + File = unbox args[1].Value + Line = unbox args[2].Value + Find = unbox args[3].Value + Replace = unbox args[4].Value + TestName = m.Name + DeclaringType = t.FullName }) + |> Seq.toArray)) + |> Array.sortBy (fun mutation -> mutation.Id) + +let findMutation id mutations = + mutations + |> Array.tryFind (fun mutation -> mutation.Id = id) + |> Option.defaultWith (fun () -> fail $"Unknown mutation id: {id}") + +let lineNumberAt (text: string) index = + let mutable line = 1 + for i in 0 .. index - 1 do + if text[i] = '\n' then + line <- line + 1 + line + +let replaceNearestOccurrence (mutation: MutationCase) (text: string) = + let rec collect fromIndex acc = + let idx = text.IndexOf(mutation.Find, fromIndex, StringComparison.Ordinal) + if idx < 0 then List.rev acc + else collect (idx + 1) (idx :: acc) + + let matches = collect 0 [] + match matches with + | [] -> fail $"Could not find '{mutation.Find}' in {mutation.File}" + | _ -> + let chosen = + matches + |> List.minBy (fun idx -> abs (lineNumberAt text idx - mutation.Line)) + text.Remove(chosen, mutation.Find.Length).Insert(chosen, mutation.Replace) + +let printMutation mutation = + printfn "id: %s" mutation.Id + printfn "test: %s.%s" mutation.DeclaringType mutation.TestName + printfn "file: %s:%d" mutation.File mutation.Line + printfn "find: %s" mutation.Find + printfn "replace: %s" mutation.Replace + +let tempRoot = Path.Combine(Path.GetTempPath(), $"fscheck-mutants.{Guid.NewGuid():N}") +let worktreePath = Path.Combine(tempRoot, "worktree") +let patchPath = Path.Combine(tempRoot, "current.patch") + +let cleanup () = + if Directory.Exists worktreePath then + let _ = captureProcess repoRoot "git" [ "worktree"; "remove"; "--force"; worktreePath ] + () + if Directory.Exists tempRoot then + Directory.Delete(tempRoot, true) + +let createWorktree () = + Directory.CreateDirectory(tempRoot) |> ignore + let exitCode, diffText, stderr = captureProcess repoRoot "git" [ "diff"; "--binary"; "HEAD" ] + if exitCode <> 0 then fail stderr + File.WriteAllText(patchPath, diffText) + + let exitCode2, stdout2, stderr2 = captureProcess repoRoot "git" [ "rev-parse"; "HEAD" ] + if exitCode2 <> 0 then fail stderr2 + let baseRev = stdout2.Trim() + + let exitCode3, _, stderr3 = captureProcess repoRoot "git" [ "worktree"; "add"; "--detach"; worktreePath; baseRev ] + if exitCode3 <> 0 then fail stderr3 + + if FileInfo(patchPath).Length > 0L then + let exitCode4, _, stderr4 = captureProcess worktreePath "git" [ "apply"; patchPath ] + if exitCode4 <> 0 then fail stderr4 + +let testFilter mutation = $"FullyQualifiedName~{mutation.TestName}" + +let runMutation (mutation: MutationCase) = + let targetFile = Path.Combine(worktreePath, mutation.File.Replace('/', Path.DirectorySeparatorChar)) + if not (File.Exists targetFile) then + fail $"Target file does not exist in worktree: {targetFile}" + + let originalText = File.ReadAllText targetFile + let mutatedText = replaceNearestOccurrence mutation originalText + File.WriteAllText(targetFile, mutatedText) + + printfn "==> %s: %s" mutation.Id mutation.TestName + let exitCode = runProcess worktreePath "dotnet" [ "test"; testProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--nologo" ] + File.WriteAllText(targetFile, originalText) + + if exitCode = 0 then + printfn "SURVIVED %s" mutation.Id + false + else + printfn "KILLED %s" mutation.Id + true + +let mutations = mutationCases () + +if Array.isEmpty mutations then + fail "No mutation cases were discovered in the test assembly." + +match options.Command with +| List -> + mutations + |> Array.iter (fun mutation -> + printfn "%s\t%s\t%s:%d" mutation.Id mutation.TestName mutation.File mutation.Line) +| Show id -> + let mutation = findMutation id mutations + printMutation mutation +| Run ids -> + let requested = + match ids with + | [] -> mutations |> Array.toList + | _ -> ids |> List.map (fun id -> findMutation id mutations) + + try + createWorktree () + let killed = + requested + |> List.map runMutation + let survivors = killed |> List.filter not |> List.length + if survivors = 0 then + printfn "All requested mutants were killed." + else + fail $"{survivors} mutant(s) survived." + finally + cleanup () From f578ca42cb01900df3f6b1a359171276c52cee83 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 22:44:29 +0200 Subject: [PATCH 3/6] Don't consider a failed build as a killed mutant --- verify-coverage-mutants.fsx | 41 ++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/verify-coverage-mutants.fsx b/verify-coverage-mutants.fsx index 08212ef..ed5f988 100644 --- a/verify-coverage-mutants.fsx +++ b/verify-coverage-mutants.fsx @@ -12,6 +12,11 @@ type MutationCase = TestName: string DeclaringType: string } +type MutationOutcome = + | Killed + | Survived + | BuildFailed + type Command = | List | Show of string @@ -203,15 +208,26 @@ let runMutation (mutation: MutationCase) = File.WriteAllText(targetFile, mutatedText) printfn "==> %s: %s" mutation.Id mutation.TestName - let exitCode = runProcess worktreePath "dotnet" [ "test"; testProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--nologo" ] - File.WriteAllText(targetFile, originalText) + let buildExitCode = + runProcess worktreePath "dotnet" [ "build"; testProjectPath; "--configuration"; options.Configuration; "--nologo" ] - if exitCode = 0 then - printfn "SURVIVED %s" mutation.Id - false - else - printfn "KILLED %s" mutation.Id - true + let outcome = + if buildExitCode <> 0 then + printfn "BUILD FAILED %s" mutation.Id + BuildFailed + else + let testExitCode = + runProcess worktreePath "dotnet" [ "test"; testProjectPath; "--configuration"; options.Configuration; "--filter"; testFilter mutation; "--no-build"; "--nologo" ] + + if testExitCode = 0 then + printfn "SURVIVED %s" mutation.Id + Survived + else + printfn "KILLED %s" mutation.Id + Killed + + File.WriteAllText(targetFile, originalText) + outcome let mutations = mutationCases () @@ -234,13 +250,14 @@ match options.Command with try createWorktree () - let killed = + let outcomes = requested |> List.map runMutation - let survivors = killed |> List.filter not |> List.length - if survivors = 0 then + let survivors = outcomes |> List.filter ((=) Survived) |> List.length + let buildFailures = outcomes |> List.filter ((=) BuildFailed) |> List.length + if survivors = 0 && buildFailures = 0 then printfn "All requested mutants were killed." else - fail $"{survivors} mutant(s) survived." + fail $"{survivors} mutant(s) survived. {buildFailures} mutant(s) failed to build." finally cleanup () From f354fde01ad6319554aa1d3b30359e8d6d51d35c Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 22:50:25 +0200 Subject: [PATCH 4/6] Add .NET gitignore --- .gitignore | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45cc92d --- /dev/null +++ b/.gitignore @@ -0,0 +1,210 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Bb]in/ +[Oo]bj/ +[Ee]xt/ +[Ll]og/ +[Ll]ogs/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSBuild response files +!MSBuild.rsp +!Directory.Build.rsp + +# Visual Studio 14+ cache/options directory +.vs/ + +# Visual Studio 15+ auto generated files +Generated\ Files/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*~.* +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# Microsoft Fakes +FakesAssemblies/ + +# CodeRush personal settings +.cr/personal + +# JetBrains Rider +*.sln.iml From eed9190ddbe9b9a599f6663a9c12352553076f07 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 22:50:45 +0200 Subject: [PATCH 5/6] Ignore .codex --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 45cc92d..33afe2c 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,6 @@ FakesAssemblies/ # JetBrains Rider *.sln.iml + +# Codex +.codex From b05c2dc0a260d749f4d84c699d07e6366e80b621 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Mon, 27 Apr 2026 22:51:25 +0200 Subject: [PATCH 6/6] Add example project --- example-tests/Example.Tests/Calculator.fs | 10 +++++++++ .../Example.Tests/CalculatorTests.fs | 22 +++++++++++++++++++ .../Example.Tests/Example.Tests.fsproj | 21 ++++++++++++++++++ .../Example.Tests/MutationCaseAttribute.fs | 13 +++++++++++ 4 files changed, 66 insertions(+) create mode 100644 example-tests/Example.Tests/Calculator.fs create mode 100644 example-tests/Example.Tests/CalculatorTests.fs create mode 100644 example-tests/Example.Tests/Example.Tests.fsproj create mode 100644 example-tests/Example.Tests/MutationCaseAttribute.fs diff --git a/example-tests/Example.Tests/Calculator.fs b/example-tests/Example.Tests/Calculator.fs new file mode 100644 index 0000000..cfcce3b --- /dev/null +++ b/example-tests/Example.Tests/Calculator.fs @@ -0,0 +1,10 @@ +namespace Example + +module Calculator = + let addOne value = value + 1 + + let absoluteDifference left right = + if left >= right then left - right else right - left + + let isLeapYear year = + year % 4 = 0 && (year % 100 <> 0 || year % 400 = 0) diff --git a/example-tests/Example.Tests/CalculatorTests.fs b/example-tests/Example.Tests/CalculatorTests.fs new file mode 100644 index 0000000..f05ee54 --- /dev/null +++ b/example-tests/Example.Tests/CalculatorTests.fs @@ -0,0 +1,22 @@ +namespace Example.Tests + +open Example +open Mutannot +open Xunit + +type CalculatorTests() = + [] + [] + member _.AddOne_increments() = + Assert.Equal(42, Calculator.addOne 41) + + [] + [] + member _.AbsoluteDifference_preserves_order() = + Assert.Equal(7, Calculator.absoluteDifference 10 3) + + [] + [ 0", "year % 100 = 0")>] + member _.LeapYear_handles_centuries() = + Assert.True(Calculator.isLeapYear 2000) + Assert.False(Calculator.isLeapYear 1900) diff --git a/example-tests/Example.Tests/Example.Tests.fsproj b/example-tests/Example.Tests/Example.Tests.fsproj new file mode 100644 index 0000000..c8c7dce --- /dev/null +++ b/example-tests/Example.Tests/Example.Tests.fsproj @@ -0,0 +1,21 @@ + + + net10.0 + false + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/example-tests/Example.Tests/MutationCaseAttribute.fs b/example-tests/Example.Tests/MutationCaseAttribute.fs new file mode 100644 index 0000000..12e6475 --- /dev/null +++ b/example-tests/Example.Tests/MutationCaseAttribute.fs @@ -0,0 +1,13 @@ +namespace Mutannot + +open System + +[] +type MutationCaseAttribute(id: string, file: string, line: int, find: string, replace: string) = + inherit Attribute() + + member _.Id = id + member _.File = file + member _.Line = line + member _.Find = find + member _.Replace = replace