629 lines
30 KiB
Go
629 lines
30 KiB
Go
// Command tmmutate plants a catalogued source mutation, runs one package's tests, and puts the source
|
|
// back — the adversarial half of the battery, as a tool instead of as a script somebody rewrites.
|
|
//
|
|
// WHY IT IS IN THE REPOSITORY. A gate is only worth what it CATCHES, and the only way to know is to
|
|
// break the thing on purpose and watch the battery go red. Two packs and one acceptance in a row have
|
|
// each written this harness from scratch in a scratch directory, and it died with the session every
|
|
// time; the acceptance of the bank-decisions door then found EIGHT places where a gate was blind, which
|
|
// is what a catalogue that outlives a session is for. The catalogue is the record: every entry names a
|
|
// property somebody decided was worth guarding, and re-running it says whether that is still true.
|
|
//
|
|
// WHY IT FINGERPRINTS. A planted mutation looks exactly like production code. One run of the previous
|
|
// harness was killed by a tool timeout before its cleanup ran, and the planting sat in the tree for
|
|
// forty minutes; an independent reviewer took measurements off that tree and reported a defect that did
|
|
// not exist. So: the sha256 of every target is taken before the edit and checked after the restore, the
|
|
// process restores on SIGINT/SIGTERM, and a mismatch is a loud failure rather than a log line.
|
|
//
|
|
// # from backend/ — `go run` needs it AND the tar below is anchored to `.`, so the guard is not decoration:
|
|
// # run one directory up and the archive takes the whole repository, .env included.
|
|
// [ -f go.mod ] && d=$(mktemp -d) && trap 'rm -rf "$d"' EXIT INT TERM \
|
|
// && tar -cf "$d/tree.tar" --exclude=./bin --exclude='./.env*' . \
|
|
// && tar -xf "$d/tree.tar" -C "$d" && rm -f "$d/tree.tar"
|
|
// go run ./cmd/tmmutate -root "$d" # every mutation in the catalogue
|
|
// go run ./cmd/tmmutate -root "$d" -id A-lock-arbiter,G-byte-gate
|
|
// go run ./cmd/tmmutate -root "$d" -battery # the subset `make mutations` runs
|
|
// go run ./cmd/tmmutate -root "$d" -logs /tmp/mutlogs # keep each mutation's full test output
|
|
//
|
|
// ⚠ THE RECIPE EXCLUDES .env AND bin/, and that is not tidiness. `cp -a backend <dir>` — what this header
|
|
// used to show — copies the API keys into a shared /tmp and leaves them there for as long as the copy
|
|
// lives; measured on one review fan-out, 123 copies of them at once. `cp -a` into an EXISTING directory
|
|
// also nests instead of replacing, so a later `rm -f <dir>/.env` misses the file it was written to remove.
|
|
// bin/ is 9.7 MB of the 17 and nothing here runs the vet tool. `make mutations` does the same thing for the
|
|
// battery subset; this form is for running the catalogue by hand.
|
|
//
|
|
// ⚠ `-root` has no default and the copy is not a suggestion: the whole-catalogue fingerprint below is
|
|
// taken when the tool starts and can only certify a tree that was clean AT THAT MOMENT. A tree another
|
|
// process is editing, or one a previously crashed run left poisoned, verifies perfectly against its own
|
|
// poisoned baseline.
|
|
//
|
|
// A mutation the battery SURVIVES is the finding. The tool exits non-zero when any mutation survives,
|
|
// so it is usable as a gate itself.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Mutation is one planting: what to break, and where the pin that must catch it lives.
|
|
type Mutation struct {
|
|
ID string `json:"id"`
|
|
// Why states the PROPERTY the planting attacks, in the words of whoever added it. It is the only part
|
|
// of an entry that cannot be re-derived, and it is what a reader needs when a mutation survives.
|
|
Why string `json:"why"`
|
|
// Package is where the pin is looked for — NOT necessarily where the edit is. A planting judged by
|
|
// the tests of the file it edits is the classic false "the mutation survived": the pin for a store
|
|
// lock lives in the pipeline package, and running the store's own tests would prove nothing.
|
|
Package string `json:"package"`
|
|
// Run optionally narrows `go test -run`. Left empty the WHOLE package runs, which is the honest
|
|
// default: a pin nobody named is still a pin.
|
|
Run string `json:"run,omitempty"`
|
|
// Battery marks an entry as part of the SUBSET the battery runs (`make mutations`). The catalogue is
|
|
// the one carrier of that composition: listing ids in the Makefile instead would mean the next entry
|
|
// somebody adds is outside the gate silently, which is the defect this gate exists to close (row 313 —
|
|
// a green catalogue must mean the mutation is CAUGHT, not that the entry exists). A full run is ~40 min
|
|
// without -race — the mode this tool runs (137 of the 225 entries drive internal/pipeline, count of
|
|
// 07.09) — so the subset is a choice, made here, per entry, by whoever knows what the entry guards.
|
|
Battery bool `json:"battery,omitempty"`
|
|
// Expect is "red" (the default) or "survives". A catalogued survivor is not a hole somebody forgot —
|
|
// it is an argued one, and the argument belongs in Why. Without this the tool could only ever report,
|
|
// because one known survivor would make it fail forever; with it, the catalogue is a GATE: any
|
|
// outcome that is not the recorded one fails the run.
|
|
Expect string `json:"expect,omitempty"`
|
|
Edits []Edit `json:"edits"`
|
|
}
|
|
|
|
// Edit is one exact textual replacement. Exact and unique on purpose: a regexp that matches twice
|
|
// mutates two things and attributes the result to one.
|
|
type Edit struct {
|
|
File string `json:"file"`
|
|
Find string `json:"find"`
|
|
Replace string `json:"replace"`
|
|
}
|
|
|
|
type original struct {
|
|
path string
|
|
body []byte
|
|
sum string
|
|
}
|
|
|
|
func main() {
|
|
root := flag.String("root", "", "REQUIRED. Module root the mutations are applied in. Point it at a COPY: this tool edits source files, and a parallel session editing the same tree turns a measurement into a corruption. No default, deliberately — the dangerous value must not be the one you get by typing nothing.")
|
|
logs := flag.String("logs", "", "directory to write each mutation's FULL test output into (empty: keep only the failing test names)")
|
|
catalog := flag.String("catalog", "", "catalogue path (default <root>/cmd/tmmutate/mutations.json)")
|
|
ids := flag.String("id", "", "comma-separated mutation ids to run (default: all)")
|
|
battery := flag.Bool("battery", false, "run the catalogue's BATTERY SUBSET (every entry marked `battery` in the catalogue). The composition lives in the catalogue and nowhere else, so an entry joins the gate by being marked, not by being listed somewhere a second time.")
|
|
timeout := flag.Duration("timeout", 20*time.Minute, "per-mutation test timeout")
|
|
flag.Parse()
|
|
if *root == "" {
|
|
fmt.Fprintln(os.Stderr, "tmmutate: -root is required (point it at a COPY of the module, not at the tree you are editing)")
|
|
os.Exit(2)
|
|
}
|
|
|
|
if *battery && *ids != "" {
|
|
fmt.Fprintln(os.Stderr, "tmmutate: -battery and -id are two selections; pass one")
|
|
os.Exit(2)
|
|
}
|
|
if err := run(*root, *catalog, *ids, *battery, *timeout, *logs); err != nil {
|
|
fmt.Fprintf(os.Stderr, "tmmutate: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(root, catalog, ids string, battery bool, timeout time.Duration, logs string) error {
|
|
if catalog == "" {
|
|
catalog = filepath.Join(root, "cmd", "tmmutate", "mutations.json")
|
|
}
|
|
raw, err := os.ReadFile(catalog)
|
|
if err != nil {
|
|
return fmt.Errorf("read the catalogue: %w", err)
|
|
}
|
|
var all []Mutation
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&all); err != nil {
|
|
return fmt.Errorf("parse the catalogue: %w", err)
|
|
}
|
|
want := map[string]bool{}
|
|
for _, id := range strings.Split(ids, ",") {
|
|
if id = strings.TrimSpace(id); id != "" {
|
|
want[id] = true
|
|
}
|
|
}
|
|
// The catalogue is data a human writes, so it is VALIDATED rather than trusted. DisallowUnknownFields
|
|
// catches a misspelled key; it says nothing about a key that is simply absent, and an entry with no
|
|
// `package` would have run `go test` with an empty pattern and reported whatever came back.
|
|
seen := map[string]bool{}
|
|
for i, m := range all {
|
|
switch {
|
|
case m.ID == "":
|
|
return fmt.Errorf("catalogue entry %d has no id", i)
|
|
case seen[m.ID]:
|
|
return fmt.Errorf("the catalogue has two entries with id %q", m.ID)
|
|
case m.Package == "":
|
|
return fmt.Errorf("%s: no package — the pin is looked for SOMEWHERE, and an empty pattern is not a somewhere", m.ID)
|
|
case m.Why == "":
|
|
return fmt.Errorf("%s: no `why` — the property a planting attacks is the only part of an entry that cannot be re-derived", m.ID)
|
|
case m.Expect != "" && m.Expect != "red" && m.Expect != "survives":
|
|
return fmt.Errorf("%s: expect is %q, want \"red\" or \"survives\"", m.ID, m.Expect)
|
|
case m.Battery && m.Expect == "survives":
|
|
// An argued survivor is a legitimate catalogue entry and a useless GATE member: the run is
|
|
// green because the mutation was NOT caught, which is the exact sentence the gate exists to
|
|
// stop anyone from reading as "caught". Refused structurally rather than left to discipline.
|
|
return fmt.Errorf("%s: an entry with expect \"survives\" cannot be in the battery subset — it would make the gate green by NOT being caught", m.ID)
|
|
case len(m.Edits) == 0:
|
|
return fmt.Errorf("%s: the entry has no edits", m.ID)
|
|
}
|
|
for j, e := range m.Edits {
|
|
if e.File == "" || e.Find == "" {
|
|
return fmt.Errorf("%s: edit %d has no file or no anchor", m.ID, j)
|
|
}
|
|
}
|
|
seen[m.ID] = true
|
|
}
|
|
for id := range want {
|
|
if !seen[id] {
|
|
return fmt.Errorf("no mutation %q in the catalogue", id)
|
|
}
|
|
}
|
|
if battery {
|
|
for _, m := range all {
|
|
if m.Battery {
|
|
want[m.ID] = true
|
|
}
|
|
}
|
|
if len(want) == 0 {
|
|
// An empty selection would run nothing and exit 0 — a gate that passes by having no members.
|
|
return fmt.Errorf("the catalogue marks no entry `battery`, so the battery subset is empty; a gate with no members proves nothing")
|
|
}
|
|
}
|
|
|
|
// Every file the catalogue touches, fingerprinted ONCE while the tree is known good and re-checked
|
|
// before each mutation. Belt and braces on purpose: the per-mutation restore verifies itself too, but
|
|
// a restore that verifies against the wrong baseline verifies nothing — which is exactly how this
|
|
// tool failed the first time it was used.
|
|
//
|
|
// ⚠ A FILE THIS PASS CANNOT READ IS THAT ENTRY'S ROT, NOT THE RUN'S DEATH. It used to `return` here,
|
|
// and that quietly undid the whole point of making a moved ANCHOR per-entry: a renamed or deleted file
|
|
// is the commonest way a catalogue goes stale, and one of them stopped the run BEFORE entry one —
|
|
// taking every healthy entry behind it down unseen, `-id` runs included, because this loop walks the
|
|
// whole catalogue and not the selection. Measured: a catalogue with one such entry printed
|
|
// «fingerprint …: no such file or directory» and ran nothing at all.
|
|
baseline := map[string]string{}
|
|
rottedFile := map[string]string{} // mutation id → why its file could not be fingerprinted
|
|
for _, m := range all {
|
|
for _, e := range m.Edits {
|
|
path := filepath.Join(root, e.File)
|
|
if _, have := baseline[path]; have {
|
|
continue
|
|
}
|
|
body, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
// Recorded against the ENTRY, so the run reports it in place and still ends non-zero. The
|
|
// file is deliberately left out of the baseline: nothing may be measured against a file
|
|
// this pass never saw.
|
|
rottedFile[m.ID] = fmt.Sprintf("%s: %v (the file moved or was deleted under the catalogue)", m.ID, rerr)
|
|
continue
|
|
}
|
|
baseline[path] = hash(body)
|
|
}
|
|
}
|
|
pristine := func(when string) error {
|
|
for path, sum := range baseline {
|
|
body, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
return fmt.Errorf("%s: re-read %s: %w", when, path, rerr)
|
|
}
|
|
if hash(body) != sum {
|
|
return fmt.Errorf("%s: TREE IS NOT PRISTINE at %s — a planting is still in it; every result after this point is measured against mutated code", when, path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// THE PACKAGE MUST BE GREEN BEFORE ANYTHING IS PLANTED, once per package.
|
|
//
|
|
// Without this the tool's central sentence — «this pin catches this mutation» — rests on an exit code
|
|
// that a build failure, a pre-existing red, a flake, a timeout or a mistyped package pattern produce
|
|
// just as well. Demonstrated against this very tool: an entry whose `find` equals its `replace` (the
|
|
// source is byte-identical afterwards) was reported RED and the gate exited 0. A baseline is what
|
|
// makes a later red ATTRIBUTABLE.
|
|
green := map[string]bool{}
|
|
rottedPkg := map[string]string{}
|
|
for _, m := range all {
|
|
if len(want) > 0 && !want[m.ID] {
|
|
continue
|
|
}
|
|
if green[m.Package] {
|
|
continue
|
|
}
|
|
fmt.Printf("baseline %s …\n", m.Package)
|
|
base := exec.Command("go", "test", m.Package, "-count=1", fmt.Sprintf("-timeout=%s", timeout))
|
|
base.Dir = root
|
|
if out, berr := base.CombinedOutput(); berr != nil {
|
|
// ⚠ A PACKAGE PATH THAT NO LONGER RESOLVES IS ROT, and it is the third way a catalogue goes
|
|
// stale after a moved anchor and a moved file. It used to `return` here, so one renamed package
|
|
// stopped the run before entry one and took every healthy entry with it — the same shape the
|
|
// other two were fixed out of. A package that resolves and is genuinely RED still stops the
|
|
// run: a verdict measured against a package that was already failing is unattributable, which
|
|
// is the whole reason this baseline exists.
|
|
if setupFailed(out) {
|
|
rottedPkg[m.Package] = fmt.Sprintf("package %s does not resolve (it was renamed, moved or deleted under the catalogue)", m.Package)
|
|
continue
|
|
}
|
|
return fmt.Errorf("%s is NOT GREEN before any mutation is planted — every verdict about it would be unattributable:\n%s",
|
|
m.Package, tailOf(out))
|
|
}
|
|
green[m.Package] = true
|
|
}
|
|
|
|
survived, ran := []string{}, 0
|
|
for _, m := range all {
|
|
if len(want) > 0 && !want[m.ID] {
|
|
continue
|
|
}
|
|
if why, dead := rottedPkg[m.Package]; dead {
|
|
ran++
|
|
fmt.Printf("ROTTED %-28s %s\n", m.ID, why)
|
|
survived = append(survived, m.ID+" (package moved)")
|
|
continue
|
|
}
|
|
if why, dead := rottedFile[m.ID]; dead {
|
|
// Reported in place, before anything is planted: the entry guards nothing, and the run goes on.
|
|
ran++
|
|
fmt.Printf("ROTTED %-28s %s\n", m.ID, why)
|
|
survived = append(survived, m.ID+" (file moved)")
|
|
continue
|
|
}
|
|
if err := pristine("before " + m.ID); err != nil {
|
|
return err
|
|
}
|
|
ran++
|
|
verdict, detail, err := one(root, m, timeout, logs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wantRed := m.Expect != "survives"
|
|
switch {
|
|
case verdict == rotted:
|
|
// Counted as an unexpected outcome, exactly like NOTHING: an entry whose anchor moved guards
|
|
// nothing, and the run must still end non-zero. What it no longer does is stop the run.
|
|
fmt.Printf("ROTTED %-28s %s\n", m.ID, detail)
|
|
// The summary label stays generic: the DETAIL line above already says which kind of rot this
|
|
// was, and a label naming one kind for all of them would be the same over-claim in miniature —
|
|
// «anchor moved» printed for an entry whose run filter had been renamed.
|
|
survived = append(survived, m.ID+" (rotted)")
|
|
case verdict == inconclusive:
|
|
// NOT a red. The package did not build, or the run died before a single test reported — so
|
|
// nothing was judged and the entry proves nothing. Counted as an unexpected outcome on
|
|
// purpose: a catalogue entry that stopped compiling (because the code it patches moved) is a
|
|
// property that has silently stopped being guarded.
|
|
fmt.Printf("NOTHING %-28s %s\n", m.ID, detail)
|
|
survived = append(survived, m.ID+" (nothing ran)")
|
|
case verdict == red && wantRed:
|
|
fmt.Printf("RED %-28s %s\n", m.ID, detail)
|
|
case verdict == red:
|
|
fmt.Printf("RED! %-28s %s — the catalogue records this one as a SURVIVOR and it is now caught; update the entry\n", m.ID, detail)
|
|
survived = append(survived, m.ID+" (unexpectedly caught)")
|
|
case wantRed:
|
|
fmt.Printf("SURVIVED %-28s %s — %s\n", m.ID, m.Package, m.Why)
|
|
survived = append(survived, m.ID)
|
|
default:
|
|
fmt.Printf("survives %-28s %s — recorded, argued: %s\n", m.ID, m.Package, m.Why)
|
|
}
|
|
}
|
|
if err := pristine("after the last mutation"); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\n%d mutation(s) run, %d unexpected outcome(s)\n", ran, len(survived))
|
|
rot, rotted := catalogueRot(root, all)
|
|
// ⛔ PRINTED EVEN WHEN CLEAN, and that is the point: a banner that appears only on failure leaves an
|
|
// operator unable to tell a sweep that found nothing from a sweep that did not happen. The rule this
|
|
// tool exists to serve is the same one — a zero is worth nothing without the count it was taken over.
|
|
fmt.Printf("anchors swept: %d of %d entr(ies) rotten\n", rotted, len(all))
|
|
if len(rot) > 0 {
|
|
// Counted over ENTRIES, not messages: one entry can contribute two lines (two dead anchors), and
|
|
// «2 of 1 entr(ies)» is the kind of banner that makes a reader distrust the number rather than the
|
|
// tree.
|
|
fmt.Printf("\nCATALOGUE ROT — %d of %d entr(ies) can no longer be planted as written:\n", rotted, len(all))
|
|
for _, r := range rot {
|
|
fmt.Println(" " + r)
|
|
}
|
|
}
|
|
if len(survived) > 0 || len(rot) > 0 {
|
|
// The two lists are reported as two NUMBERS, not concatenated: an entry whose anchor is gone is
|
|
// found twice — once by the run loop that tried to plant it, once by the static sweep — and adding
|
|
// the lists together made one problem read as two, with the id printed twice in the same line.
|
|
return fmt.Errorf("not what the catalogue records: %d unexpected outcome(s) [%s]; %d rotted entr(ies)",
|
|
len(survived), strings.Join(survived, "; "), rotted)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// catalogueRot statically checks every entry of the WHOLE catalogue — not the selection — for the two ways
|
|
// a planting stops being possible: the file is gone, or the anchor no longer occurs exactly once.
|
|
//
|
|
// WHY THE WHOLE CATALOGUE. A subset run that says nothing about the other entries is the shape backlog row
|
|
// 313 names: the green reads as "the catalogue holds" while it only means "these ten do". The sweep costs
|
|
// milliseconds (it reads each named file once, runs no test), so there is no reason to buy the weaker
|
|
// sentence. ⚠ It cannot see the THIRD kind of rot — a package path that no longer resolves — which needs a
|
|
// build; that one is still found only by running the entry.
|
|
//
|
|
// ⚠ A RE-CUT WILL TURN THIS RED ON PURPOSE. Entries anchored in the chunker, the manifest and the export
|
|
// are exactly the code a boundary change moves, and the fix is to re-target them in the same commit — the
|
|
// point is that nobody gets to find that out later.
|
|
func catalogueRot(root string, all []Mutation) (msgs []string, entries int) {
|
|
bodies := map[string]string{}
|
|
// unreadable is a SEPARATE set rather than an empty body, because an empty body is also what a
|
|
// zero-length file legitimately has: overloading one sentinel for both made every entry after the first
|
|
// on the same path silently skipped, and a whole-tree miss then printed «47 of 222» while 222 entries
|
|
// were dead.
|
|
unreadable := map[string]bool{}
|
|
// The ENTRY count is kept as the ids are collected, not recovered afterwards by parsing the messages
|
|
// this function just wrote: an id may itself contain a colon, and a banner that disagrees with its own
|
|
// detail lines is the class this whole gate exists to remove.
|
|
bad := map[string]bool{}
|
|
var out []string
|
|
for _, m := range all {
|
|
// ⛔ COUNTED THE WAY THE PLANTING COUNTS, on the ACCUMULATING text and per file, mirroring one()'s
|
|
// own loop. An entry may hold several edits of one file whose later anchors are unique only after
|
|
// the earlier replacements — its own error message says so ("two edits of this mutation overlap").
|
|
// ⚠ Today no entry needs it: all 231 anchors are unique in the pristine body, so both schemes report
|
|
// zero. Seven entries hold >1 edit of one file and are the ones the pristine scheme could misjudge —
|
|
// that is a risk count, not a measured miss.
|
|
patched := map[string]string{}
|
|
for _, e := range m.Edits {
|
|
path := filepath.Join(root, e.File)
|
|
if unreadable[path] {
|
|
bad[m.ID] = true
|
|
out = append(out, fmt.Sprintf("%s: %s is unreadable", m.ID, e.File))
|
|
continue
|
|
}
|
|
body, seen := bodies[path]
|
|
if !seen {
|
|
raw, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
bad[m.ID] = true
|
|
out = append(out, fmt.Sprintf("%s: %s is unreadable (%v)", m.ID, e.File, rerr))
|
|
unreadable[path] = true
|
|
continue
|
|
}
|
|
body = string(raw)
|
|
bodies[path] = body
|
|
}
|
|
cur, started := patched[path]
|
|
if !started {
|
|
cur = body
|
|
}
|
|
if n := strings.Count(cur, e.Find); n != 1 {
|
|
bad[m.ID] = true
|
|
out = append(out, fmt.Sprintf("%s: the anchor occurs %d times in %s, it must occur exactly once", m.ID, n, e.File))
|
|
continue
|
|
}
|
|
patched[path] = strings.Replace(cur, e.Find, e.Replace, 1)
|
|
}
|
|
}
|
|
return out, len(bad)
|
|
}
|
|
|
|
var failLine = regexp.MustCompile(`(?m)^\s*--- FAIL: (\S+)`)
|
|
|
|
// The three verdicts. INCONCLUSIVE is the one this tool lacked: «go test exited non-zero» is also what a
|
|
// build failure, a mistyped package pattern and a timeout produce, and calling any of those RED is how a
|
|
// property stops being guarded without anybody being told.
|
|
type verdict int
|
|
|
|
const (
|
|
survivedTests verdict = iota
|
|
red
|
|
inconclusive
|
|
// ROTTED is the fourth, and it exists because the third was not enough: an anchor that no longer
|
|
// occurs exactly once says the SOURCE MOVED under the catalogue, which is neither a survivor nor a
|
|
// build failure. It used to abort the whole run — so one stale anchor cost the tool the only property
|
|
// that makes a catalogue a catalogue, «it goes whole», and the rot then hid every entry after it.
|
|
// Measured: `AF-keys-file-reason-covers-all` rotted at 8adcb86 and stopped the run at entry 33 of 120
|
|
// for two packs, while `-id` runs kept passing and nobody saw it.
|
|
rotted
|
|
)
|
|
|
|
// tailOf keeps the last few lines of a command's output for an error message.
|
|
func tailOf(out []byte) string {
|
|
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
|
|
if len(lines) > 12 {
|
|
lines = lines[len(lines)-12:]
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// one plants a mutation, runs the package's tests, and restores the tree whatever happens.
|
|
func one(root string, m Mutation, timeout time.Duration, logs string) (v verdict, detail string, err error) {
|
|
if len(m.Edits) == 0 {
|
|
return survivedTests, "", fmt.Errorf("%s: the entry has no edits", m.ID)
|
|
}
|
|
// `saved` is read by the signal goroutine while this one appends to it, so it is guarded. Not
|
|
// theoretical: the goroutine's whole job is to run DURING the write loop, which is exactly when the
|
|
// slice is growing, and a torn read there restores the wrong bytes or none.
|
|
var mu sync.Mutex
|
|
var saved []original
|
|
restore := func() error {
|
|
mu.Lock()
|
|
snapshot := append([]original(nil), saved...)
|
|
mu.Unlock()
|
|
var bad []string
|
|
for _, o := range snapshot {
|
|
if werr := os.WriteFile(o.path, o.body, 0o644); werr != nil {
|
|
bad = append(bad, fmt.Sprintf("%s: %v", o.path, werr))
|
|
continue
|
|
}
|
|
// The restore is VERIFIED, not assumed. This is the whole reason the tool exists rather than a
|
|
// shell one-liner: a planting left behind reads as production code.
|
|
now, rerr := os.ReadFile(o.path)
|
|
if rerr != nil || hash(now) != o.sum {
|
|
bad = append(bad, fmt.Sprintf("%s: TREE IS NOT PRISTINE after restore", o.path))
|
|
}
|
|
}
|
|
if len(bad) > 0 {
|
|
return fmt.Errorf("%s: could not restore: %s", m.ID, strings.Join(bad, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
// A signal must not be able to leave a planting behind either.
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case s := <-sig:
|
|
// The restore's verdict is REPORTED, not discarded. Printing «restored» after a failed restore
|
|
// is the one message this tool must never produce: it tells the operator the tree is clean at
|
|
// the exact moment it is not.
|
|
if rerr := restore(); rerr != nil {
|
|
fmt.Fprintf(os.Stderr, "tmmutate: %v — THE TREE STILL CARRIES THE PLANTING, restore it by hand before trusting anything\n", rerr)
|
|
os.Exit(3)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "tmmutate: restored on %v\n", s)
|
|
os.Exit(2)
|
|
case <-done:
|
|
}
|
|
}()
|
|
defer func() {
|
|
// RESTORE FIRST, then stop the handler. The other order leaves a window in which the deferred
|
|
// restore has not run and the handler that would have covered it is already gone.
|
|
rerr := restore()
|
|
close(done)
|
|
signal.Stop(sig)
|
|
// A restore failure OUTRANKS whatever else went wrong: every later verdict is measured against a
|
|
// tree that is not the tree. Swallowing it behind an earlier error was how it could stay quiet.
|
|
if rerr != nil {
|
|
if err != nil {
|
|
err = errors.Join(rerr, err)
|
|
} else {
|
|
err = rerr
|
|
}
|
|
}
|
|
}()
|
|
|
|
// ONE READ AND ONE WRITE PER FILE, and the pristine copy is taken from the FIRST read.
|
|
//
|
|
// ⚠ The obvious loop — read, patch, write, once per edit — is wrong the moment a mutation carries TWO
|
|
// edits to ONE file, and it is wrong in the worst possible way: the second edit's "pristine" copy is
|
|
// the already-patched file, so the restore writes the patched content back and the sha256 check
|
|
// compares it against the patched hash and passes. This session shipped exactly that, ran two full
|
|
// rounds with it, and every mutation after the two-edit one measured a tree that still carried a
|
|
// planting — three pins were recorded as catching mutations they had nothing to do with. The tool
|
|
// exists to make that impossible, so this ordering is the contract and not an implementation detail.
|
|
edits := map[string][]Edit{}
|
|
var order []string
|
|
for _, e := range m.Edits {
|
|
if _, seen := edits[e.File]; !seen {
|
|
order = append(order, e.File)
|
|
}
|
|
edits[e.File] = append(edits[e.File], e)
|
|
}
|
|
for _, file := range order {
|
|
path := filepath.Join(root, file)
|
|
body, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
// ROT, not a run failure. The moved-file case itself is caught EARLIER, by the fingerprint pass,
|
|
// which records it against the entry and lets the run continue; what this branch covers is the
|
|
// narrow race in between — a file that disappears after that pass and before this read.
|
|
// ⚠ An earlier version of this comment said the moved-file case "still aborts the whole run".
|
|
// That was true when it was written and false one edit later, and it is the exact class of
|
|
// defect this tool exists to find: a sentence that outlived the code it described.
|
|
return rotted, fmt.Sprintf("%s: %v (the file moved or was deleted under the catalogue)", m.ID, rerr), nil
|
|
}
|
|
mu.Lock()
|
|
saved = append(saved, original{path: path, body: body, sum: hash(body)})
|
|
mu.Unlock()
|
|
patched := string(body)
|
|
for _, e := range edits[file] {
|
|
if n := strings.Count(patched, e.Find); n != 1 {
|
|
// A VERDICT, not an error: the deferred restore has already been armed for every file read
|
|
// so far, so returning here leaves the tree pristine and lets the remaining entries run.
|
|
return rotted, fmt.Sprintf("%s: the anchor occurs %d times in %s, it must occur exactly once (the source moved under the catalogue, or two edits of this mutation overlap)", m.ID, n, e.File), nil
|
|
}
|
|
patched = strings.Replace(patched, e.Find, e.Replace, 1)
|
|
}
|
|
if werr := os.WriteFile(path, []byte(patched), 0o644); werr != nil {
|
|
return survivedTests, "", fmt.Errorf("%s: %w", m.ID, werr)
|
|
}
|
|
}
|
|
|
|
args := []string{"test", m.Package, "-count=1", fmt.Sprintf("-timeout=%s", timeout)}
|
|
if m.Run != "" {
|
|
args = append(args, "-run", m.Run)
|
|
}
|
|
cmd := exec.Command("go", args...)
|
|
cmd.Dir = root
|
|
out, terr := cmd.CombinedOutput()
|
|
// The OUTPUT, not only the verdict. A mutation this session ran reported a pin that had in fact
|
|
// failed for an unrelated reason, and with only the test NAMES there was no way to tell the two apart
|
|
// without re-running by hand — which is the same class of false result the fingerprinting is against.
|
|
if logs != "" {
|
|
// Loud: a `-logs` directory that cannot be made is an operator who asked for evidence and will
|
|
// find none, and this tool exists because a verdict without evidence was believed once already.
|
|
if werr := os.MkdirAll(logs, 0o755); werr != nil {
|
|
return survivedTests, "", fmt.Errorf("%s: -logs %s: %w", m.ID, logs, werr)
|
|
}
|
|
if werr := os.WriteFile(filepath.Join(logs, m.ID+".log"), out, 0o644); werr != nil {
|
|
return survivedTests, "", fmt.Errorf("%s: write the log: %w", m.ID, werr)
|
|
}
|
|
}
|
|
// ⚠ A `run` FILTER THAT MATCHES NOTHING IS ROT, NOT A SURVIVOR. `go test -run TestRenamedAway` runs
|
|
// zero tests and exits 0, so a renamed pin turned every entry that names it green forever: an
|
|
// expect-RED entry printed SURVIVED (a finding that is really a NOTHING — no test was judged), and an
|
|
// expect-`survives` entry printed exactly what the catalogue records and kept the whole gate at exit 0.
|
|
// The catalogue has entries with run filters precisely so a red is attributable to ONE pin, which is
|
|
// what makes this failure quiet: the narrower the filter, the more a rename costs.
|
|
if strings.Contains(string(out), "no tests to run") {
|
|
return rotted, fmt.Sprintf("%s: the run filter %q matched no test — it was renamed or removed, and this entry has been asserting nothing", m.ID, m.Run), nil
|
|
}
|
|
if terr == nil {
|
|
return survivedTests, "", nil
|
|
}
|
|
names := failLine.FindAllStringSubmatch(string(out), -1)
|
|
var caught []string
|
|
for _, n := range names {
|
|
caught = append(caught, n[1])
|
|
}
|
|
if len(caught) == 0 {
|
|
// NOT a red. The package is green by baseline, so a non-zero exit with no test naming itself means
|
|
// nothing was judged: the source did not build, the pattern matched no package, or the run died.
|
|
// The first version of this tool called that RED, and two catalogue entries whose anchors had
|
|
// stopped compiling were recorded for a whole session as caught by tests that never ran.
|
|
return inconclusive, m.Package + ": no test reported — the planting did not build, or the run died before any test (property: " + m.Why + ")", nil
|
|
}
|
|
if len(caught) > 4 {
|
|
caught = append(caught[:4], fmt.Sprintf("+%d more", len(names)-4))
|
|
}
|
|
return red, m.Package + " " + strings.Join(caught, ", "), nil
|
|
}
|
|
|
|
func hash(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// setupFailed reports whether `go test` refused the PACKAGE PATTERN itself rather than running anything —
|
|
// the package was renamed, moved or deleted under the catalogue. Distinguishable from a genuine red in the
|
|
// output, and it must be, because the two get opposite treatment: rot is recorded per entry and the run
|
|
// goes on, a real red stops everything.
|
|
func setupFailed(out []byte) bool {
|
|
s := string(out)
|
|
return strings.Contains(s, "[setup failed]") ||
|
|
strings.Contains(s, "directory not found") ||
|
|
strings.Contains(s, "matched no packages")
|
|
}
|