Compare commits

..

10 commits

6 changed files with 255 additions and 66 deletions

14
.editorconfig Normal file
View file

@ -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

View file

@ -6,17 +6,22 @@ open Xunit
type CalculatorTests() =
[<Fact>]
[<MutationCase("calc-add-one", "Example.Tests/Calculator.fs", 4, "value + 1", "value - 1")>]
member _.AddOne_increments() =
Assert.Equal(42, Calculator.addOne 41)
[<MutationCase("""
diff --git a/Example.Tests/Calculator.fs b/Example.Tests/Calculator.fs
index cfcce3b..39be7f3 100644
--- a/Example.Tests/Calculator.fs
+++ b/Example.Tests/Calculator.fs
@@ -1,7 +1,7 @@
namespace Example
[<Fact>]
[<MutationCase("calc-abs-diff-branch", "Example.Tests/Calculator.fs", 7, "left - right", "right - left")>]
member _.AbsoluteDifference_preserves_order() =
Assert.Equal(7, Calculator.absoluteDifference 10 3)
module Calculator =
- let addOne value = value + 1
+ let addOne value = value - 1
[<Fact>]
[<MutationCase("calc-leap-year-century", "Example.Tests/Calculator.fs", 10, "year % 100 <> 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)

View file

@ -3,11 +3,7 @@ namespace Mutannot
open System
[<AttributeUsage(AttributeTargets.Method, AllowMultiple = true)>]
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

View file

@ -1,7 +1,12 @@
open System
open System.IO
open System.Reflection
open System.Runtime.InteropServices
open Fli
[<EntryPoint>]
let main argv =
type MutationCase = { TestName: string; Patch: string }
let ensureCleanWorkingDirectory () =
let gitState =
cli {
Exec "git"
@ -12,6 +17,123 @@ let main argv =
if gitState.Text <> None then
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"
Arguments [ "build"; projectPath ]
Output(new StreamWriter(Console.OpenStandardOutput()))
}
|> Command.execute
|> 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"
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<obj>.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
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}"
Patch = attr.ConstructorArguments[0].Value :?> string |> unindented }
| _ -> None))
|> Seq.toList
[<EntryPoint>]
let main argv =
if argv.Length <> 1 then
eprintfn "Usage: mutannot <path/to/project.csproj|fsproj>"
exit 1
ensureCleanWorkingDirectory ()
AppDomain.CurrentDomain.ProcessExit.Add(fun _ -> restore ())
let projectPath = argv[0]
for mutationCase in getMutationCases projectPath do
printfn "MUTATION\n\n%s" <| mutationCase.Patch
applyPatch mutationCase.Patch
runTest projectPath mutationCase.TestName
restore ()
0

59
flake.lock generated
View file

@ -1,5 +1,47 @@
{
"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"
}
},
"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,
@ -18,8 +60,25 @@
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"git-temp-commit": "git-temp-commit",
"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",

View file

@ -1,57 +1,50 @@
{
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 =
{ 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,
git-temp-commit,
}:
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
git-temp-commit.packages.${system}.default
];
};
}
);
}