Files
cgalo5758 2bc0bd1610 Add a tool that applies deliberate breaks one at a time
scripts/mutation-check reads a JSON list of breaks, applies each to the tree
one at a time, restores the file byte for byte, and reports CAUGHT, MISSED,
WRONG-MESSAGE, NOT-FOUND, SKIPPED or CONTROL-FAILED for each.

New tests: TestApplyAndRestoreAreByteExact, TestOldTextMustOccurOnce,
TestSidePicksPatternAndMessage, TestARecordWithASideRunsOnlyThere,
TestOutcomes, TestStatelessListStopsOnAFailingControl,
TestControlRunsOncePerDistinctTest,
TestStatefulListPreparesAndControlsEachBreak,
TestStatefulControlFailureMarksOnlyThatBreak, TestPrepareFailureStopsTheList,
TestCommandRecordsAreClassifiedByExitAndText,
TestCommandRecordReadsTheInspectedFileAndNeverAStaleOne,
TestCommandControlFailsWhenTheTextIsAlreadyThere,
TestSummarizeExitsOneUnlessEveryBreakIsCaught.
2026-10-07 16:21:58 -05:00

480 lines
14 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Command mutation-check applies deliberate breaks to the tree one at a time
// and reports which of them a test or a command catches.
//
// A list is a JSON file with an optional "prepare" command and a list of
// breaks. A test record names a file, the exact text to replace and its
// replacement, a package, and for each side the -run pattern and the message
// the failing test must print:
//
// {"name": "...", "file": "...", "old": "...", "new": "...", "pkg": "./internal/db",
// "run_old": "^TestOld$", "run_new": "^TestNew$", "want_old": "...", "want_new": "..."}
//
// A command record replaces pkg and the run patterns with a "cmd" that bash
// runs from the repository root, the exit status "exit" it must end with and,
// optionally, "inspect", a file the command writes that is removed before each
// run of that record. It carries the breaks that only a command shows: the
// project lint, golangci-lint, or a screens run, which exits 0 whatever it
// finds. A record with a "side" runs only on that side; a break whose edit
// differs between the two commits is two records with one name.
//
// -side old|new picks run_old and want_old, or run_new and want_new. A list
// without "prepare" is stateless: every break's test or command runs once
// unbroken first, and the list stops unless each passes. A list with "prepare"
// is stateful: before each break, prepare runs and then that break's unbroken
// run, and a break whose control run fails is reported CONTROL-FAILED, not
// classified. After the last break of a stateful list, prepare runs once more
// on the restored tree.
//
// Each break is CAUGHT, MISSED (the run passed), WRONG-MESSAGE (it failed
// without the wanted text), NOT-FOUND (the old text does not occur exactly
// once), SKIPPED (the named test skipped, so it did not run) or
// CONTROL-FAILED. The exit status is 1 unless every break is CAUGHT, and 2 on
// a usage or list error. Run it from the repository root:
//
// go run ./scripts/mutation-check -list breaks.json -side new
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
)
const (
caught = "CAUGHT"
missed = "MISSED"
wrongMessage = "WRONG-MESSAGE"
notFound = "NOT-FOUND"
skipped = "SKIPPED"
controlFailed = "CONTROL-FAILED"
)
type list struct {
Prepare string `json:"prepare"`
Breaks []record `json:"breaks"`
}
// record is one break: a test record when Cmd is empty, a command record
// otherwise.
type record struct {
Name string `json:"name"`
Side string `json:"side"`
File string `json:"file"`
Old string `json:"old"`
New string `json:"new"`
Pkg string `json:"pkg"`
RunOld string `json:"run_old"`
RunNew string `json:"run_new"`
WantOld string `json:"want_old"`
WantNew string `json:"want_new"`
Cmd string `json:"cmd"`
Exit int `json:"exit"`
Inspect string `json:"inspect"`
}
func (r record) isCommand() bool { return r.Cmd != "" }
func (r record) run(side string) string {
if side == "old" {
return r.RunOld
}
return r.RunNew
}
func (r record) want(side string) string {
if side == "old" {
return r.WantOld
}
return r.WantNew
}
// result is what one test or command run printed and how it ended.
type result struct {
out string
exit int
}
// executor runs the things a list names. The real one starts processes; the
// tests fake it.
type executor interface {
Prepare(ctx context.Context, cmd string) error
Test(ctx context.Context, pkg, run string) (result, error)
Command(ctx context.Context, cmd string) (result, error)
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
os.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr))
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("mutation-check", flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("list", "", "JSON list of breaks")
side := fs.String("side", "", "old or new")
if err := fs.Parse(args); err != nil {
return 2
}
if *path == "" || (*side != "old" && *side != "new") {
sayln(stderr, "mutation-check: -list <file.json> and -side old|new are required")
return 2
}
l, err := readList(*path)
if err != nil {
sayln(stderr, "mutation-check:", err)
return 2
}
root, err := os.Getwd()
if err != nil {
sayln(stderr, "mutation-check:", err)
return 2
}
r := &runner{ex: shell{dir: root}, root: root, side: *side, out: stdout}
counts, err := r.runList(ctx, l)
if err != nil {
sayln(stderr, "mutation-check:", err)
return 1
}
return summarize(stdout, counts)
}
func readList(path string) (list, error) {
var l list
data, err := os.ReadFile(path)
if err != nil {
return l, err
}
if err := json.Unmarshal(data, &l); err != nil {
return l, fmt.Errorf("%s: %w", path, err)
}
return l, nil
}
// summarize prints the total line and returns the exit status.
func summarize(w io.Writer, counts map[string]int) int {
total := 0
for _, n := range counts {
total += n
}
say(w, "%d breaks: %d %s", total, counts[caught], caught)
for _, k := range []string{missed, wrongMessage, notFound, skipped, controlFailed} {
if counts[k] > 0 {
say(w, ", %d %s", counts[k], k)
}
}
sayln(w)
if counts[caught] != total {
return 1
}
return 0
}
type runner struct {
ex executor
root string
side string
out io.Writer
}
// runList runs the list's breaks for the runner's side and returns how many
// ended in each outcome. An error means the list could not be run at all: a
// stateless control run failed, or prepare did.
func (r *runner) runList(ctx context.Context, l list) (map[string]int, error) {
breaks := r.forSide(l.Breaks)
if l.Prepare == "" {
if err := r.controlAll(ctx, breaks); err != nil {
return nil, err
}
}
counts := map[string]int{}
for _, b := range breaks {
if err := ctx.Err(); err != nil {
return counts, err
}
outcome, detail, err := r.one(ctx, l.Prepare, b)
if err != nil {
return counts, err
}
counts[outcome]++
r.report(outcome, b.Name, detail)
}
if l.Prepare != "" {
if err := r.ex.Prepare(ctx, l.Prepare); err != nil {
return counts, fmt.Errorf("prepare after the last break: %w", err)
}
}
return counts, nil
}
// forSide keeps the records that run on the runner's side.
func (r *runner) forSide(all []record) []record {
var out []record
for _, b := range all {
if b.Side == "" || b.Side == r.side {
out = append(out, b)
}
}
return out
}
func (r *runner) report(outcome, name, detail string) {
say(r.out, "%-14s %s\n", outcome, name)
if detail != "" {
sayln(r.out, indent(detail))
}
}
func indent(s string) string {
return " " + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n ")
}
// controlAll runs every distinct unbroken test or command once and fails
// unless each passes.
func (r *runner) controlAll(ctx context.Context, breaks []record) error {
done := map[string]bool{}
for _, b := range breaks {
key := r.controlKey(b)
if done[key] {
continue
}
done[key] = true
why, err := r.control(ctx, b)
if err != nil {
return err
}
if why != "" {
return fmt.Errorf("control run of %q failed: %s", b.Name, why)
}
}
return nil
}
func (r *runner) controlKey(b record) string {
if b.isCommand() {
return "cmd\x00" + b.Cmd + "\x00" + b.Inspect + "\x00" + r.side + b.want(r.side)
}
return "test\x00" + b.Pkg + "\x00" + b.run(r.side) + "\x00" + b.want(r.side)
}
// control runs the unbroken test or command and returns why it did not pass,
// or "" when it did.
func (r *runner) control(ctx context.Context, b record) (string, error) {
res, seen, err := r.execute(ctx, b)
if err != nil {
return "", err
}
want := b.want(r.side)
switch {
case !b.isCommand() && didNotRun(res):
return "the named test did not run or skipped\n" + tail(res.out), nil
case res.exit != 0:
return fmt.Sprintf("exit %d\n%s", res.exit, tail(res.out)), nil
case want != "" && strings.Contains(seen, want):
return fmt.Sprintf("%q is already in the unbroken output", want), nil
}
return "", nil
}
// one applies a break, runs it and classifies the run. For a stateful list it
// first prepares and runs the control.
func (r *runner) one(ctx context.Context, prepare string, b record) (string, string, error) {
if prepare != "" {
if err := r.ex.Prepare(ctx, prepare); err != nil {
return "", "", fmt.Errorf("prepare before %q: %w", b.Name, err)
}
why, err := r.control(ctx, b)
if err != nil {
return "", "", err
}
if why != "" {
return controlFailed, why, nil
}
}
restore, outcome, err := r.apply(b)
if err != nil {
return "", "", err
}
if outcome != "" {
return outcome, "old text not found exactly once in " + b.File, nil
}
defer restore()
if prepare != "" {
if err := r.ex.Prepare(ctx, prepare); err != nil {
return "", "", fmt.Errorf("prepare after applying %q: %w", b.Name, err)
}
}
res, seen, err := r.execute(ctx, b)
if err != nil {
return "", "", err
}
outcome, detail := r.classify(b, res, seen)
return outcome, detail, nil
}
// apply replaces the record's old text in its file and returns a function
// that writes the original bytes back. The outcome is NOT-FOUND when the old
// text does not occur exactly once, and the file is then untouched.
func (r *runner) apply(b record) (func(), string, error) {
path := filepath.Join(r.root, b.File)
orig, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
if strings.Count(string(orig), b.Old) != 1 {
return nil, notFound, nil
}
info, err := os.Stat(path)
if err != nil {
return nil, "", err
}
broken := strings.Replace(string(orig), b.Old, b.New, 1)
if err := os.WriteFile(path, []byte(broken), info.Mode().Perm()); err != nil {
return nil, "", err
}
restore := func() {
if err := os.WriteFile(path, orig, info.Mode().Perm()); err != nil {
say(os.Stderr, "mutation-check: restoring %s: %v\n", path, err)
}
}
return restore, "", nil
}
// execute runs the record's test or command. seen is the text the want is
// looked for in: the output, plus the inspected file for a command record.
func (r *runner) execute(ctx context.Context, b record) (result, string, error) {
if !b.isCommand() {
res, err := r.ex.Test(ctx, b.Pkg, b.run(r.side))
return res, res.out, err
}
inspect := ""
if b.Inspect != "" {
inspect = filepath.Join(r.root, b.Inspect)
if err := os.Remove(inspect); err != nil && !errors.Is(err, os.ErrNotExist) {
return result{}, "", err
}
}
res, err := r.ex.Command(ctx, b.Cmd)
if err != nil {
return res, "", err
}
seen := res.out
if inspect != "" {
if data, err := os.ReadFile(inspect); err == nil {
seen += "\n" + string(data)
}
}
return res, seen, nil
}
// classify decides what a broken run means.
func (r *runner) classify(b record, res result, seen string) (string, string) {
want := b.want(r.side)
if !b.isCommand() {
return classifyTest(res, want)
}
found := want != "" && strings.Contains(seen, want)
switch {
case res.exit == b.Exit && found:
return caught, ""
case res.exit != b.Exit && res.exit == 0:
return missed, fmt.Sprintf("exited 0, want %d", b.Exit)
case res.exit != b.Exit:
return wrongMessage, fmt.Sprintf("exit %d, want %d\n%s", res.exit, b.Exit, tail(seen))
case b.Exit == 0:
return missed, fmt.Sprintf("%q is in neither the output nor the inspected file", want)
}
return wrongMessage, fmt.Sprintf("%q is in neither the output nor the inspected file\n%s", want, tail(seen))
}
func classifyTest(res result, want string) (string, string) {
switch {
case didNotRun(res):
return skipped, "the named test did not run or skipped"
case res.exit == 0:
return missed, "the test passed"
case want != "" && strings.Contains(res.out, want):
return caught, ""
}
return wrongMessage, fmt.Sprintf("%q is not in the output\n%s", want, tail(res.out))
}
// didNotRun reports a run whose named test never passed or failed: go test
// printed no test result, or only skips.
func didNotRun(res result) bool {
if strings.Contains(res.out, "[no tests to run]") {
return true
}
if strings.Contains(res.out, "--- PASS") || strings.Contains(res.out, "--- FAIL") {
return false
}
return strings.Contains(res.out, "--- SKIP") || res.exit == 0
}
// tail keeps the last lines of an output for a report.
func tail(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > 12 {
lines = lines[len(lines)-12:]
}
return strings.Join(lines, "\n")
}
// shell is the real executor: it starts go test and bash from the repository
// root.
type shell struct{ dir string }
func (s shell) Prepare(ctx context.Context, cmd string) error {
res, err := s.bash(ctx, cmd)
if err != nil {
return err
}
if res.exit != 0 {
return fmt.Errorf("exit %d\n%s", res.exit, tail(res.out))
}
return nil
}
func (s shell) Test(ctx context.Context, pkg, run string) (result, error) {
return s.start(exec.CommandContext(ctx, "go", "test", "-count=1", "-v", "-run", run, pkg))
}
func (s shell) Command(ctx context.Context, cmd string) (result, error) {
return s.bash(ctx, cmd)
}
func (s shell) bash(ctx context.Context, cmd string) (result, error) {
return s.start(exec.CommandContext(ctx, "bash", "-c", cmd))
}
func (s shell) start(c *exec.Cmd) (result, error) {
c.Dir = s.dir
out, err := c.CombinedOutput()
var exit *exec.ExitError
switch {
case err == nil:
return result{out: string(out)}, nil
case errors.As(err, &exit) && c.ProcessState != nil && c.ProcessState.Exited():
return result{out: string(out), exit: exit.ExitCode()}, nil
}
return result{out: string(out)}, err
}
// say and sayln write a report line; a failed write to a terminal or a test
// buffer has nowhere to be reported.
func say(w io.Writer, format string, args ...any) { _, _ = fmt.Fprintf(w, format, args...) }
func sayln(w io.Writer, args ...any) { _, _ = fmt.Fprintln(w, args...) }