From ccb456d5b0dd5e135394be6213e9c4587341e81d Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 06:37:31 +0200 Subject: [PATCH 01/10] Add .editorconfig --- .editorconfig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ececb87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +indent_style = space +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.nix] +indent_size = 2 + +[*.{fs,fsi,fsx}] +indent_size = 4 From 442fe5d5d8742219b2a32be2622a20d5fed79d16 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 06:54:47 +0200 Subject: [PATCH 02/10] Use flake-utils in flake.nix --- flake.lock | 34 ++++++++++++++++++++++++ flake.nix | 78 ++++++++++++++++++++++-------------------------------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/flake.lock b/flake.lock index 6908e25..20db77f 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1776877367, @@ -18,8 +36,24 @@ }, "root": { "inputs": { + "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 16f31af..6add518 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,43 @@ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; }; outputs = - { self, nixpkgs }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forEachSystem = - f: nixpkgs.lib.genAttrs systems (system: f system (import nixpkgs { inherit system; })); - in { - formatter = forEachSystem (_system: pkgs: pkgs.nixfmt-rfc-style); + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.default = pkgs.buildDotnetModule { + pname = "mutannot"; + version = "0.1.0"; + src = ./Mutannot; + projectFile = "Mutannot.fsproj"; + nugetDeps = ./Mutannot/deps.nix; + executables = [ "mutannot" ]; + dotnet-sdk = pkgs.dotnet-sdk_10; + dotnet-runtime = pkgs.dotnet-sdk_10; + useDotnetFromEnv = true; - packages = forEachSystem ( - _system: pkgs: - let - mutannot = pkgs.buildDotnetModule { - pname = "mutannot"; - version = "0.1.0"; - src = ./Mutannot; - projectFile = "Mutannot.fsproj"; - nugetDeps = ./Mutannot/deps.nix; - executables = [ "mutannot" ]; - dotnet-sdk = pkgs.dotnet-sdk_10; - dotnet-runtime = pkgs.dotnet-sdk_10; - useDotnetFromEnv = true; - - meta = { - mainProgram = "mutannot"; - }; + meta = { + mainProgram = "mutannot"; }; - in - { - default = mutannot; - mutannot = mutannot; - } - ); + }; - devShells = forEachSystem ( - _system: pkgs: { - default = pkgs.mkShell { - packages = [ - pkgs.git - pkgs.dotnet-sdk_10 - ]; - }; - } - ); - }; + devShells.default = pkgs.mkShell { + packages = [ + pkgs.git + pkgs.dotnet-sdk_10 + ]; + }; + } + ); } From c2e5a01e26c8ba4640e1f79bad2873e1e22d5ab3 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 06:57:20 +0200 Subject: [PATCH 03/10] Add git-temp-commit to devshell --- flake.lock | 25 +++++++++++++++++++++++++ flake.nix | 7 +++++++ 2 files changed, 32 insertions(+) diff --git a/flake.lock b/flake.lock index 20db77f..8244c7c 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,30 @@ "type": "github" } }, + "git-temp-commit": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778481212, + "narHash": "sha256-Vy0ufQ51CHkamX+XB8hhgohBHJesKli0jF503NuSY20=", + "ref": "main", + "rev": "622b553f46920a2f3cc92f26c1f49cabb612de5f", + "revCount": 2, + "type": "git", + "url": "https://codeberg.org/svenvanheugten/git-temp-commit.git" + }, + "original": { + "ref": "main", + "type": "git", + "url": "https://codeberg.org/svenvanheugten/git-temp-commit.git" + } + }, "nixpkgs": { "locked": { "lastModified": 1776877367, @@ -37,6 +61,7 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "git-temp-commit": "git-temp-commit", "nixpkgs": "nixpkgs" } }, diff --git a/flake.nix b/flake.nix index 6add518..f3fe06f 100644 --- a/flake.nix +++ b/flake.nix @@ -2,6 +2,11 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + git-temp-commit = { + url = "git+https://codeberg.org/svenvanheugten/git-temp-commit.git?ref=main"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; }; outputs = @@ -9,6 +14,7 @@ self, nixpkgs, flake-utils, + git-temp-commit, }: flake-utils.lib.eachDefaultSystem ( system: @@ -36,6 +42,7 @@ packages = [ pkgs.git pkgs.dotnet-sdk_10 + git-temp-commit.packages.${system}.default ]; }; } From b4292969673878fad392fc6ae1543fa82b017117 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 07:01:45 +0200 Subject: [PATCH 04/10] Add usage message --- Mutannot/Program.fs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index e7195fc..1de7c20 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -2,6 +2,10 @@ open Fli [] let main argv = + if argv.Length <> 1 then + eprintfn "Usage: mutannot " + exit 1 + let gitState = cli { Exec "git" @@ -12,6 +16,6 @@ let main argv = if gitState.Text <> None then eprintfn "Uncommitted changes. Refusing to run." - exit 1 + exit 2 0 From 05deb1f08945bc29b1ca78a2205128f6ea93462e Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 07:05:45 +0200 Subject: [PATCH 05/10] Build the project --- Mutannot/Program.fs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 1de7c20..98784fb 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -1,3 +1,5 @@ +open System +open System.IO open Fli [] @@ -18,4 +20,13 @@ let main argv = eprintfn "Uncommitted changes. Refusing to run." exit 2 + cli { + Exec "dotnet" + Arguments [ "build"; argv[0] ] + Output(new StreamWriter(Console.OpenStandardOutput())) + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + 0 From 10ddbef963a51f6ed86555f2cb79c49256f3e218 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 07:58:33 +0200 Subject: [PATCH 06/10] Scan assembly for mutations --- Mutannot/Program.fs | 74 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 98784fb..5c3a1ac 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -1,13 +1,77 @@ open System open System.IO +open System.Reflection +open System.Runtime.InteropServices open Fli +type MutationCase = { TestName: string; Id: string } + +let ensureBuilt projectPath = + cli { + Exec "dotnet" + Arguments [ "build"; projectPath ] + Output(new StreamWriter(Console.OpenStandardOutput())) + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + +let getAssemblyPath projectPath = + cli { + Exec "dotnet" + Arguments [ "msbuild"; projectPath; "--getProperty:TargetPath" ] + } + |> Command.execute + |> Output.toText + +let getMetadataLoadContext (assemblyPath: string) = + // This allows us to inspect assemblies regardless of the platform that they were built for + // https://learn.microsoft.com/en-us/dotnet/standard/assembly/inspect-contents-using-metadataloadcontext + let assemblyDir = Path.GetDirectoryName assemblyPath + + let pathAssemblyResolver = + [ yield assemblyPath + yield! Directory.EnumerateFiles(assemblyDir, "*.dll") + yield! Directory.EnumerateFiles(assemblyDir, "*.exe") + yield! Directory.GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") ] + |> PathAssemblyResolver + + new MetadataLoadContext(pathAssemblyResolver, typeof.Assembly.GetName().Name) + +let getMutationCases projectPath = + ensureBuilt projectPath + + let assemblyPath = getAssemblyPath projectPath + + use metadataLoadContext = getMetadataLoadContext assemblyPath + + let assemblyTypes = + assemblyPath |> metadataLoadContext.LoadFromAssemblyPath |> _.GetTypes() + + let assemblyMethods = + assemblyTypes + |> Seq.collect _.GetMethods(BindingFlags.Public ||| BindingFlags.Instance) + + assemblyMethods + |> Seq.collect (fun m -> + m.GetCustomAttributesData() + |> Seq.choose (fun attr -> + match attr.AttributeType.FullName with + | "Mutannot.MutationCaseAttribute" -> + Some + { TestName = $"{m.DeclaringType.FullName}.{m.Name}" + Id = attr.ConstructorArguments[0].Value :?> string } + | _ -> None)) + |> Seq.toList + [] let main argv = if argv.Length <> 1 then eprintfn "Usage: mutannot " exit 1 + let projectPath = argv[0] + let gitState = cli { Exec "git" @@ -20,13 +84,7 @@ let main argv = eprintfn "Uncommitted changes. Refusing to run." exit 2 - cli { - Exec "dotnet" - Arguments [ "build"; argv[0] ] - Output(new StreamWriter(Console.OpenStandardOutput())) - } - |> Command.execute - |> Output.throwIfErrored - |> ignore + for mutationCase in getMutationCases projectPath do + printfn "%s" <| mutationCase.ToString() 0 From 2dc2c288fb41e984c1e7f25d48615e32816c3681 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 08:07:43 +0200 Subject: [PATCH 07/10] Make mutations patch-based --- Example.Tests/CalculatorTests.fs | 29 +++++++++++++++----------- Example.Tests/MutationCaseAttribute.fs | 8 ++----- Mutannot/Program.fs | 4 ++-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Example.Tests/CalculatorTests.fs b/Example.Tests/CalculatorTests.fs index 40f9b66..0a96e10 100644 --- a/Example.Tests/CalculatorTests.fs +++ b/Example.Tests/CalculatorTests.fs @@ -6,17 +6,22 @@ open Xunit type CalculatorTests() = [] - [] - member _.AddOne_increments() = - Assert.Equal(42, Calculator.addOne 41) + [] - [] - member _.AbsoluteDifference_preserves_order() = - Assert.Equal(7, Calculator.absoluteDifference 10 3) + module Calculator = + - let addOne value = value + 1 + + let addOne value = value - 1 - [] - [ 0", "year % 100 = 0")>] - member _.LeapYear_handles_centuries() = - Assert.True(Calculator.isLeapYear 2000) - Assert.False(Calculator.isLeapYear 1900) + let absoluteDifference left right = + if left >= right then left - right else right - left + + member _.AddOne_increments() = + Assert.Equal(42, Calculator.addOne 41) + """)>] + member _.AddOne_increments() = Assert.Equal(42, Calculator.addOne 41) diff --git a/Example.Tests/MutationCaseAttribute.fs b/Example.Tests/MutationCaseAttribute.fs index 12e6475..f1175e4 100644 --- a/Example.Tests/MutationCaseAttribute.fs +++ b/Example.Tests/MutationCaseAttribute.fs @@ -3,11 +3,7 @@ namespace Mutannot open System [] -type MutationCaseAttribute(id: string, file: string, line: int, find: string, replace: string) = +type MutationCaseAttribute(patch: string) = inherit Attribute() - member _.Id = id - member _.File = file - member _.Line = line - member _.Find = find - member _.Replace = replace + member _.Patch = patch diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 5c3a1ac..6cd78aa 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -4,7 +4,7 @@ open System.Reflection open System.Runtime.InteropServices open Fli -type MutationCase = { TestName: string; Id: string } +type MutationCase = { TestName: string; Patch: string } let ensureBuilt projectPath = cli { @@ -60,7 +60,7 @@ let getMutationCases projectPath = | "Mutannot.MutationCaseAttribute" -> Some { TestName = $"{m.DeclaringType.FullName}.{m.Name}" - Id = attr.ConstructorArguments[0].Value :?> string } + Patch = attr.ConstructorArguments[0].Value :?> string } | _ -> None)) |> Seq.toList From c6996d5bbfd406ec6f792e69ec807d26ef55a154 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 08:08:58 +0200 Subject: [PATCH 08/10] Move ensureCleanWorkingDirectory to own function --- Mutannot/Program.fs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 6cd78aa..5c3f6be 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -6,6 +6,19 @@ open Fli type MutationCase = { TestName: string; Patch: string } +let ensureCleanWorkingDirectory () = + let gitState = + cli { + Exec "git" + Arguments [ "status"; "--porcelain" ] + } + |> Command.execute + |> Output.throwIfErrored + + if gitState.Text <> None then + eprintfn "Uncommitted changes. Refusing to run." + exit 2 + let ensureBuilt projectPath = cli { Exec "dotnet" @@ -70,20 +83,10 @@ let main argv = eprintfn "Usage: mutannot " exit 1 + ensureCleanWorkingDirectory () + let projectPath = argv[0] - let gitState = - cli { - Exec "git" - Arguments [ "status"; "--porcelain" ] - } - |> Command.execute - |> Output.throwIfErrored - - if gitState.Text <> None then - eprintfn "Uncommitted changes. Refusing to run." - exit 2 - for mutationCase in getMutationCases projectPath do printfn "%s" <| mutationCase.ToString() From c93f87d2823ee795bc9a755c093949bf64790f77 Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 08:32:11 +0200 Subject: [PATCH 09/10] Apply the mutations --- Mutannot/Program.fs | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 5c3f6be..d3d9f06 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -19,6 +19,25 @@ let ensureCleanWorkingDirectory () = eprintfn "Uncommitted changes. Refusing to run." exit 2 +let applyPatch patch = + cli { + Exec "git" + Arguments [ "apply"; "-" ] + Input patch + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + +let restore () = + cli { + Exec "git" + Arguments [ "restore"; "--staged"; "--worktree"; "." ] + } + |> Command.execute + |> Output.throwIfErrored + |> ignore + let ensureBuilt projectPath = cli { Exec "dotnet" @@ -51,6 +70,19 @@ let getMetadataLoadContext (assemblyPath: string) = new MetadataLoadContext(pathAssemblyResolver, typeof.Assembly.GetName().Name) +let unindented (s: string) = + let lines = s.Split([| "\r\n"; "\n" |], StringSplitOptions.None) + + let indexOfFirstNonEmptyLine = + lines |> Array.findIndex (not << String.IsNullOrWhiteSpace) + + let identantionOfFirstNonEmptyLine = + lines[indexOfFirstNonEmptyLine] |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length + + lines[indexOfFirstNonEmptyLine..] + |> Seq.map (fun line -> line.Substring(min identantionOfFirstNonEmptyLine line.Length)) + |> String.concat Environment.NewLine + let getMutationCases projectPath = ensureBuilt projectPath @@ -73,7 +105,7 @@ let getMutationCases projectPath = | "Mutannot.MutationCaseAttribute" -> Some { TestName = $"{m.DeclaringType.FullName}.{m.Name}" - Patch = attr.ConstructorArguments[0].Value :?> string } + Patch = attr.ConstructorArguments[0].Value :?> string |> unindented } | _ -> None)) |> Seq.toList @@ -85,9 +117,13 @@ let main argv = ensureCleanWorkingDirectory () + AppDomain.CurrentDomain.ProcessExit.Add(fun _ -> restore ()) + let projectPath = argv[0] for mutationCase in getMutationCases projectPath do - printfn "%s" <| mutationCase.ToString() + printfn "MUTATION\n\n%s" <| mutationCase.Patch + applyPatch mutationCase.Patch + restore () 0 From bd7f0e70e1673ccec890bfa86c928c9af007601e Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Tue, 12 May 2026 08:36:28 +0200 Subject: [PATCH 10/10] Run the test after mutating --- Mutannot/Program.fs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index d3d9f06..13666be 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -48,6 +48,15 @@ let ensureBuilt projectPath = |> Output.throwIfErrored |> ignore +let runTest projectPath testName = + cli { + Exec "dotnet" + Arguments [ "test"; projectPath; "--filter"; $"FullyQualifiedName={testName}" ] + Output(new StreamWriter(Console.OpenStandardOutput())) + } + |> Command.execute + |> ignore + let getAssemblyPath projectPath = cli { Exec "dotnet" @@ -124,6 +133,7 @@ let main argv = for mutationCase in getMutationCases projectPath do printfn "MUTATION\n\n%s" <| mutationCase.Patch applyPatch mutationCase.Patch + runTest projectPath mutationCase.TestName restore () 0