diff --git a/Mutannot/Program.fs b/Mutannot/Program.fs index 6f8d0e9..8342ab2 100644 --- a/Mutannot/Program.fs +++ b/Mutannot/Program.fs @@ -20,6 +20,7 @@ type MutationOutcome = | BuildFailed type Command = + | Validate | List | Show of string | Run of string list @@ -91,7 +92,7 @@ let repoRoot = let parseArgs (args: string list) = let usage () = - fail "Usage: mutannot [--configuration Debug|Release] [--build-arg ...] [--no-build] [--list | --show | --run [id...]]" + fail "Usage: mutannot [--configuration Debug|Release] [--build-arg ...] [--no-build] [--validate | --list | --show | --run [id...]]" let rec loop configuration projectPath buildArgs noBuild remaining = match remaining with @@ -104,6 +105,12 @@ let parseArgs (args: string list) = | "--configuration" :: value :: tail -> loop value projectPath buildArgs noBuild tail | "--build-arg" :: value :: tail -> loop configuration projectPath (value :: buildArgs) noBuild tail | "--no-build" :: tail -> loop configuration projectPath buildArgs true tail + | "--validate" :: tail when tail.IsEmpty -> + { Configuration = configuration + ProjectPath = projectPath + BuildArgs = List.rev buildArgs + NoBuild = noBuild + Command = Validate } | "--list" :: tail when tail.IsEmpty -> { Configuration = configuration ProjectPath = projectPath @@ -278,6 +285,76 @@ let replaceOccurrenceOnLine (mutation: MutationCase) (text: string) = let absoluteOffset = lineStart + lineOffset text.Remove(absoluteOffset, mutation.Find.Length).Insert(absoluteOffset, mutation.Replace) +let countOccurrencesOnLine (needle: string) (lineText: string) = + if String.IsNullOrEmpty needle then + 0 + else + let mutable count = 0 + let mutable startIndex = 0 + let mutable keepSearching = true + + while keepSearching do + let index = lineText.IndexOf(needle, startIndex, StringComparison.Ordinal) + + if index < 0 then + keepSearching <- false + else + count <- count + 1 + startIndex <- index + needle.Length + + count + +let trimLineForDisplay (lineText: string) = + let trimmed = lineText.Trim() + + if String.IsNullOrEmpty trimmed then + "" + else + trimmed + +let staleMutationMessage (mutation: MutationCase) (detail: string) = + $"Mutation '{mutation.Id}' is stale: the recorded target at {mutation.File}:{mutation.Line} no longer matches the current source. {detail} Re-locate the intended mutation and update its file/line/find metadata." + +let validateMutation (repoRoot: string) (mutation: MutationCase) = + let targetFile = Path.Combine(repoRoot, mutation.File.Replace('/', Path.DirectorySeparatorChar)) + + if String.IsNullOrEmpty mutation.Find then + Some(staleMutationMessage mutation "The recorded find text is empty.") + elif not (File.Exists targetFile) then + Some(staleMutationMessage mutation $"The target file '{mutation.File}' no longer exists.") + else + let originalText = File.ReadAllText targetFile + + try + let lineStart, lineEnd = lineSpan mutation.File originalText mutation.Line + let lineText = originalText.Substring(lineStart, lineEnd - lineStart) + let occurrenceCount = countOccurrencesOnLine mutation.Find lineText + + match occurrenceCount with + | 0 -> + Some + (staleMutationMessage + mutation + $"Expected to find '{mutation.Find}' on that line, but it has changed to '{trimLineForDisplay lineText}'.") + | 1 -> None + | count -> + Some + (staleMutationMessage + mutation + $"The text '{mutation.Find}' now appears {count} times on that line, so the recorded target is no longer unique. Current line: '{trimLineForDisplay lineText}'.") + with + | UserError message -> Some(staleMutationMessage mutation message) + +let printValidationErrors errors = + eprintfn "Validation failed for %d mutation(s):" (errors |> List.length) + + for mutation, message in errors do + eprintfn "[%s] %s:%d %s" mutation.Id mutation.File mutation.Line message + +let collectValidationErrors repoRoot mutations = + mutations + |> List.choose (fun mutation -> validateMutation repoRoot mutation |> Option.map (fun message -> mutation, message)) + let printMutation mutation = printfn "id: %s" mutation.Id printfn "test: %s.%s" mutation.DeclaringType mutation.TestName @@ -412,6 +489,15 @@ let main argv = fail "No mutation cases were discovered in the test assembly." match options.Command with + | Validate -> + let validationErrors = mutations |> Array.toList |> collectValidationErrors repoRoot + + if validationErrors.IsEmpty then + printfn "Validated %d mutation(s): all still apply syntactically." mutations.Length + 0 + else + printValidationErrors validationErrors + 1 | List -> mutations |> Array.iter (fun mutation -> @@ -428,26 +514,33 @@ let main argv = | [] -> mutations |> Array.toList | _ -> ids |> List.map (fun id -> findMutation id mutations) - 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 validationErrors = collectValidationErrors repoRoot requested - try - createWorktree patchPath worktreePath + if not validationErrors.IsEmpty then + printValidationErrors validationErrors + 1 + else - let outcomes = - requested |> List.map (runMutation options project worktreePath) + 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 survivors = outcomes |> List.filter ((=) Survived) |> List.length - let buildFailures = outcomes |> List.filter ((=) BuildFailed) |> List.length + try + createWorktree patchPath worktreePath - if survivors = 0 && buildFailures = 0 then - printfn "All requested mutants were killed." - 0 - else - fail $"{survivors} mutant(s) survived. {buildFailures} mutant(s) failed to build." - finally - cleanup tempRoot worktreePath + let outcomes = + requested |> List.map (runMutation options project worktreePath) + + 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." + 0 + else + fail $"{survivors} mutant(s) survived. {buildFailures} mutant(s) failed to build." + finally + cleanup tempRoot worktreePath with | UserError message -> eprintfn "%s" message diff --git a/skills/write-mutations/SKILL.md b/skills/write-mutations/SKILL.md new file mode 100644 index 0000000..fd3bbe4 --- /dev/null +++ b/skills/write-mutations/SKILL.md @@ -0,0 +1,25 @@ +--- +name: Write mutations +description: Trigger when asked to write mutations +--- + +Annotate tests with one or more mutations (`MutationCase`s) that will cause the test to fail. + +Example: + +```fs +[] +[] +member _.AddOne_increments() = + Assert.Equal(42, Calculator.addOne 41) +``` + +In this example, + +* `calc-operator-mixup` is the mutation name, +* `Calculator/Calculator.fs` is the path to the production code (relative to the repository root), +* `4` is the number of the line to mutate, +* `value + 1` is the string to find, and +* `value - 1` is the string to replace it with. + +Verify your work with `mutannot --run <...>`.