252 lines
9.8 KiB
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)
|
|
}
|
|
}
|
|
}
|