Compare commits
2 commits
3313656db4
...
1019d3c5d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1019d3c5d0 | |||
| 5adf626209 |
2 changed files with 135 additions and 17 deletions
|
|
@ -20,6 +20,7 @@ type MutationOutcome =
|
||||||
| BuildFailed
|
| BuildFailed
|
||||||
|
|
||||||
type Command =
|
type Command =
|
||||||
|
| Validate
|
||||||
| List
|
| List
|
||||||
| Show of string
|
| Show of string
|
||||||
| Run of string list
|
| Run of string list
|
||||||
|
|
@ -91,7 +92,7 @@ let repoRoot =
|
||||||
|
|
||||||
let parseArgs (args: string list) =
|
let parseArgs (args: string list) =
|
||||||
let usage () =
|
let usage () =
|
||||||
fail "Usage: mutannot <path/to/project.fsproj> [--configuration Debug|Release] [--build-arg <value> ...] [--no-build] [--list | --show <id> | --run [id...]]"
|
fail "Usage: mutannot <path/to/project.fsproj> [--configuration Debug|Release] [--build-arg <value> ...] [--no-build] [--validate | --list | --show <id> | --run [id...]]"
|
||||||
|
|
||||||
let rec loop configuration projectPath buildArgs noBuild remaining =
|
let rec loop configuration projectPath buildArgs noBuild remaining =
|
||||||
match remaining with
|
match remaining with
|
||||||
|
|
@ -104,6 +105,12 @@ let parseArgs (args: string list) =
|
||||||
| "--configuration" :: value :: tail -> loop value projectPath buildArgs noBuild tail
|
| "--configuration" :: value :: tail -> loop value projectPath buildArgs noBuild tail
|
||||||
| "--build-arg" :: value :: tail -> loop configuration projectPath (value :: buildArgs) noBuild tail
|
| "--build-arg" :: value :: tail -> loop configuration projectPath (value :: buildArgs) noBuild tail
|
||||||
| "--no-build" :: tail -> loop configuration projectPath buildArgs true 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 ->
|
| "--list" :: tail when tail.IsEmpty ->
|
||||||
{ Configuration = configuration
|
{ Configuration = configuration
|
||||||
ProjectPath = projectPath
|
ProjectPath = projectPath
|
||||||
|
|
@ -278,6 +285,76 @@ let replaceOccurrenceOnLine (mutation: MutationCase) (text: string) =
|
||||||
let absoluteOffset = lineStart + lineOffset
|
let absoluteOffset = lineStart + lineOffset
|
||||||
text.Remove(absoluteOffset, mutation.Find.Length).Insert(absoluteOffset, mutation.Replace)
|
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
|
||||||
|
"<empty line>"
|
||||||
|
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 =
|
let printMutation mutation =
|
||||||
printfn "id: %s" mutation.Id
|
printfn "id: %s" mutation.Id
|
||||||
printfn "test: %s.%s" mutation.DeclaringType mutation.TestName
|
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."
|
fail "No mutation cases were discovered in the test assembly."
|
||||||
|
|
||||||
match options.Command with
|
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 ->
|
| List ->
|
||||||
mutations
|
mutations
|
||||||
|> Array.iter (fun mutation ->
|
|> Array.iter (fun mutation ->
|
||||||
|
|
@ -428,26 +514,33 @@ let main argv =
|
||||||
| [] -> mutations |> Array.toList
|
| [] -> mutations |> Array.toList
|
||||||
| _ -> ids |> List.map (fun id -> findMutation id mutations)
|
| _ -> ids |> List.map (fun id -> findMutation id mutations)
|
||||||
|
|
||||||
let tempRoot = Path.Combine(Path.GetTempPath(), $"fscheck-mutants.{Guid.NewGuid():N}")
|
let validationErrors = collectValidationErrors repoRoot requested
|
||||||
let worktreePath = Path.Combine(tempRoot, "worktree")
|
|
||||||
let patchPath = Path.Combine(tempRoot, "current.patch")
|
|
||||||
|
|
||||||
try
|
if not validationErrors.IsEmpty then
|
||||||
createWorktree patchPath worktreePath
|
printValidationErrors validationErrors
|
||||||
|
1
|
||||||
|
else
|
||||||
|
|
||||||
let outcomes =
|
let tempRoot = Path.Combine(Path.GetTempPath(), $"fscheck-mutants.{Guid.NewGuid():N}")
|
||||||
requested |> List.map (runMutation options project worktreePath)
|
let worktreePath = Path.Combine(tempRoot, "worktree")
|
||||||
|
let patchPath = Path.Combine(tempRoot, "current.patch")
|
||||||
|
|
||||||
let survivors = outcomes |> List.filter ((=) Survived) |> List.length
|
try
|
||||||
let buildFailures = outcomes |> List.filter ((=) BuildFailed) |> List.length
|
createWorktree patchPath worktreePath
|
||||||
|
|
||||||
if survivors = 0 && buildFailures = 0 then
|
let outcomes =
|
||||||
printfn "All requested mutants were killed."
|
requested |> List.map (runMutation options project worktreePath)
|
||||||
0
|
|
||||||
else
|
let survivors = outcomes |> List.filter ((=) Survived) |> List.length
|
||||||
fail $"{survivors} mutant(s) survived. {buildFailures} mutant(s) failed to build."
|
let buildFailures = outcomes |> List.filter ((=) BuildFailed) |> List.length
|
||||||
finally
|
|
||||||
cleanup tempRoot 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
|
||||||
with
|
with
|
||||||
| UserError message ->
|
| UserError message ->
|
||||||
eprintfn "%s" message
|
eprintfn "%s" message
|
||||||
|
|
|
||||||
25
skills/write-mutations/SKILL.md
Normal file
25
skills/write-mutations/SKILL.md
Normal file
|
|
@ -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
|
||||||
|
[<Fact>]
|
||||||
|
[<MutationCase("calc-operator-mixup", "Calculator/Calculator.fs", 4, "value + 1", "value - 1")>]
|
||||||
|
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 <mutation id 1> <mutation id 2> <...>`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue