textmachine/backend/internal/pipeline/operatormessages_test.go

252 lines
9.8 KiB
Go

package pipeline
import (
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// operatormessages_test.go pins the TEXT of every operator message this package emits without stopping
// the run.
//
// ⚠ WHY A CATALOGUE AND NOT MORE SUBSTRING ASSERTS. The messages that rot are exactly the ones nothing
// else reads. A message that STOPS the run is held in place by the test that asserts the stop — change
// its meaning and that test says so. A warning is read by a person, once, in a log, and by nothing else:
// the S14 conflict warning was guarded by one substring covering one phrase of it, so when a rule change
// made the sentence false the substring still matched, and what caught it was an unrelated battery test.
// The localizing half of that message — both rows, both windows, the firing key — was guarded by nothing
// at all. Adding another substring assert would have moved the boundary, not closed it: the next message
// somebody adds is outside it again, silently. This is the same shape as flagseverity_test.go, and for
// the same reason: a hand-written list of what to check rots exactly like the code it checks.
//
// WHAT THE CATALOGUE ASSERTS, and it is not truth. It asserts DELIBERATENESS. Nothing here can know
// whether "the signed term wins" is true of the code below it; what it can do is make the day somebody
// changes those words the day they read the sentence again. A wording change is then a diff line in
// testdata/operator-messages.txt with the function's name beside it, which a reviewer can judge.
//
// THE BOUNDARY, stated so the next reader does not have to infer it:
//
// - COVERED: every Warn/WarnContext/Error/ErrorContext call on this package's logger, and every
// bankInputs.remark, whose message is a string LITERAL. These run on and leave only a line behind.
// - COVERED as a count, not as text: the same calls whose message is BUILT at run time. They cannot be
// pinned as text, so the catalogue holds their site with <dynamic> in place of the message — which
// still makes a NEW one visible, and a message quietly converted from a literal into a built string
// (the way to leave this gate without touching it) shows up as a changed line.
// - NOT COVERED, deliberately: Info/InfoContext — progress, not a decision, and a reader who misreads
// one loses nothing; and fmt.Errorf — a message that ends the run already has a test that asserts the
// ending, so it has a second reader and does not rot alone.
// - NOT COVERED, and named so it is not mistaken for coverage: the ARGUMENTS. Renaming a key or
// dropping a value from the structured tail is invisible here.
func TestEveryOperatorMessageIsCatalogued(t *testing.T) {
got := operatorMessagesOfPackage(t)
// The walk finding nothing at all would report a green "the catalogue matches" over an empty set, which
// is the failure mode this whole file exists to prevent. 41 source files carry ~100 of these; a floor
// well under that still catches a walk that stopped seeing the package.
if len(got) < 60 {
t.Fatalf("only %d operator messages found — the walk stopped seeing the package", len(got))
}
const catalogue = "testdata/operator-messages.txt"
raw, err := os.ReadFile(catalogue)
if err != nil {
t.Fatalf("read %s: %v", catalogue, err)
}
var want []string
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimRight(line, "\r"); line != "" && !strings.HasPrefix(line, "#") {
want = append(want, line)
}
}
sort.Strings(want)
inWant := map[string]int{}
for _, l := range want {
inWant[l]++
}
inGot := map[string]int{}
for _, l := range got {
inGot[l]++
}
var added, gone []string
for l, n := range inGot {
for i := inWant[l]; i < n; i++ {
added = append(added, l)
}
}
for l, n := range inWant {
for i := inGot[l]; i < n; i++ {
gone = append(gone, l)
}
}
sort.Strings(added)
sort.Strings(gone)
if len(added) == 0 && len(gone) == 0 {
return
}
var b strings.Builder
fmt.Fprintf(&b, "the operator messages this package emits no longer match %s.\n", catalogue)
b.WriteString("This is not a formatting gate: each line below is a sentence an operator reads and acts on,\n")
b.WriteString("and the catalogue is where a change to one is reviewed. Read the new wording against what the\n")
b.WriteString("code now does, then paste the line into the file (it is sorted; keep it that way).\n")
if len(added) > 0 {
fmt.Fprintf(&b, "\nIN THE CODE, NOT IN THE CATALOGUE (%d):\n", len(added))
for _, l := range added {
b.WriteString(l + "\n")
}
}
if len(gone) > 0 {
fmt.Fprintf(&b, "\nIN THE CATALOGUE, NOT IN THE CODE (%d) — a message was reworded, moved or removed:\n", len(gone))
for _, l := range gone {
b.WriteString(l + "\n")
}
}
t.Fatal(b.String())
}
// operatorMessagesOfPackage returns one catalogue line per operator message the package emits, sorted.
// A line is `file<TAB>function<TAB>quoted message`, and the message is quoted so a newline or a tab inside
// one cannot forge a line boundary.
func operatorMessagesOfPackage(t *testing.T) []string {
t.Helper()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read the package directory: %v", err)
}
var out []string
fset := token.NewFileSet()
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, perr := parser.ParseFile(fset, name, nil, 0)
if perr != nil {
t.Fatalf("parse %s: %v", name, perr)
}
// The enclosing function is tracked by walking declarations rather than by asking the node, because
// a message inside a closure belongs, for a reader, to the function that spells the closure.
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
ast.Inspect(fd, func(n ast.Node) bool {
msg, found := operatorMessageArg(fset, n)
if found {
out = append(out, strings.Join([]string{name, fd.Name.Name, msg}, "\t"))
}
return true
})
}
}
sort.Strings(out)
return out
}
// operatorMessageArg reports the message of one operator-facing call, quoted, or found=false when the node
// is not one. `<dynamic>` stands for a message built at run time — see the boundary above.
func operatorMessageArg(fset *token.FileSet, n ast.Node) (msg string, found bool) {
call, ok := n.(*ast.CallExpr)
if !ok {
return "", false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return "", false
}
var at int
switch sel.Sel.Name {
case "WarnContext", "ErrorContext":
at = 1
case "Warn", "Error":
at = 0 // no ctx in front of the message, unlike the *Context pair above
// err.Error() and friends are spelled the same and are not messages. The logger is what decides:
// every one in this package is reached through a field or variable named log/Log/logger, and a
// call on anything else is somebody's error being rendered.
if !isLoggerExpr(fset, sel.X) {
return "", false
}
case "remark":
at = 0
default:
return "", false
}
if sel.Sel.Name == "WarnContext" || sel.Sel.Name == "ErrorContext" {
if !isLoggerExpr(fset, sel.X) {
return "", false
}
}
if len(call.Args) <= at {
return "", false
}
lit, ok := call.Args[at].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return "<dynamic>", true
}
s, err := strconv.Unquote(lit.Value)
if err != nil {
return "<dynamic>", true
}
return strconv.Quote(s), true
}
// isLoggerExpr reports whether an expression is this package's logger. Matched on the LAST identifier of
// the rendered expression, so r.Log, e.log, logger and log all answer yes and a bare err does not.
func isLoggerExpr(fset *token.FileSet, x ast.Expr) bool {
var b strings.Builder
if err := printer.Fprint(&b, fset, x); err != nil {
return false
}
s := b.String()
if i := strings.LastIndexAny(s, ".)"); i >= 0 {
s = s[i+1:]
}
return strings.EqualFold(s, "log") || strings.EqualFold(s, "logger")
}
// TestTheCatalogueFileIsWellFormed keeps the catalogue reviewable and keeps the comparison above honest:
// an unsorted file makes a one-line wording change read as a large diff, and a line with the wrong number
// of fields compares something other than a message.
//
// ⚠ DUPLICATE LINES ARE LEGITIMATE and are deliberately not rejected — two call sites in one function do
// sometimes carry the same words (quality.go does). An earlier version of this test forbade them on the
// reasoning that one would be "guarded by the other"; running it showed the reasoning was wrong, because
// the catalogue is compared as a MULTISET, so two identical messages need two identical lines and
// rewording either one still goes red.
func TestTheCatalogueFileIsWellFormed(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "operator-messages.txt"))
if err != nil {
t.Fatalf("read the catalogue: %v", err)
}
var lines []string
for _, l := range strings.Split(string(raw), "\n") {
if l = strings.TrimRight(l, "\r"); l != "" && !strings.HasPrefix(l, "#") {
lines = append(lines, l)
}
}
if !sort.StringsAreSorted(lines) {
t.Errorf("the catalogue is not sorted; sort it so a wording change is a one-line diff")
}
// Every line must have exactly the three fields the walker writes, and the first must be a source file
// of this package — otherwise the comparison above silently compares something that is not a message.
for _, l := range lines {
if n := strings.Count(l, "\t"); n != 2 {
t.Errorf("catalogue line has %d tabs, want 2 (file<TAB>func<TAB>quoted message):\n%s", n, l)
continue
}
file := l[:strings.IndexByte(l, '\t')]
if !strings.HasSuffix(file, ".go") || strings.Contains(file, "/") {
t.Errorf("catalogue line does not start with a source file of this package:\n%s", l)
continue
}
if _, err := os.Stat(file); err != nil {
t.Errorf("catalogue names %s, which this package does not have: %v", file, err)
}
}
}