Introduce --validate

This commit is contained in:
Sven van Heugten 2026-04-29 06:54:06 +02:00
parent 3313656db4
commit 5adf626209
No known key found for this signature in database
GPG key ID: D612F88666F4F660

View file

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