carlos / platform Public

copy gate fix round 3: the checker fails closed

One evaluator derives the text of every value reaching a sink: parameters are
bound to arguments, named results and bare returns are read, fields are
sentences only if every write is, callees resolve by declared type, and a value
it cannot derive, a pattern with no text of its own, or a limit is a violation
and an unrecorded sentence. Function values of sentence-flow functions are
refused. Negative fixtures for each counterexample of the re-review.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e742cd310e9cbb3aebd57ba952c669d44f6604c4 parent 227e3a3
12 files changed, +1966 −1034
  • approved_moves_copy_scan_test.go +1570 −0
  • approved_moves_copy_test.go +205 −1030
  • testdata/moves-copy/sinks/field-reason/emit.go +23 −0
  • testdata/moves-copy/sinks/funcvalue/emit.go +29 −0
  • testdata/moves-copy/sinks/interface/emit.go +22 −0
  • testdata/moves-copy/sinks/limit/emit.go +36 −0
  • testdata/moves-copy/sinks/named-result/emit.go +12 −0
  • testdata/moves-copy/sinks/ok/emit.go +11 −4
  • testdata/moves-copy/sinks/ok/record.md +15 −0
  • testdata/moves-copy/sinks/shadow/emit.go +13 −0
  • testdata/moves-copy/sinks/sprintf-v/emit.go +18 −0
  • testdata/moves-copy/sinks/substitution/emit.go +12 −0
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go
new file mode 100644
index 000000000..5f6b8bba2
--- /dev/null
+++ b/approved_moves_copy_scan_test.go
@@ -0,0 +1,1570 @@
+// approved_moves_copy_scan_test.go
+package main
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// The analyser behind TestMovesStringsAreRecorded and TestMovesSinksAreSentences.
+//
+// It fails closed. A value that reaches an operator sink (a jsonError message,
+// a Check's Reason, a Phase 1 copy table, a sentence-returning function) must
+// be one the analyser can positively derive to text: a literal, a const, a
+// literal-format Sprintf or concatenation, a local whose every assignment is
+// such text, a call to a package helper whose every return is, a parameter
+// bound to such an argument, or a field whose every write in the scanned files
+// is. The text it derives is matched against the record. Anything it cannot
+// derive is a violation, and a pattern with no text of its own (a bare error
+// forwarded through "%v" or "" + err.Error()) is a violation too and an
+// unrecorded sentence: it is never silently dropped. Hitting a limit is a
+// violation that names file:line.
+//
+// The one place it does not look is a placeholder: an operand of a
+// concatenation or an argument of a Sprintf may be any value. The row it is
+// matched against must carry a <placeholder> at that spot, and the record is
+// where a human vouches for it.
+
+// movesSentenceFields are the struct fields that carry a sentence from the
+// function that makes it to the one that speaks it. A read of one as a
+// placeholder is forwarded, not wild, when every write to it is a sentence.
+var movesSentenceFields = map[string]bool{"Reason": true}
+
+// fwdMark stands for a value that is a sink somewhere else (a forwarded
+// parameter, a field read): it is checked where it is made, not here.
+const fwdMark = "\x01"
+
+const (
+ maxEvalDepth = 10
+ maxAlts = 64
+ maxEvalSteps = 3000000
+)
+
+type problem struct {
+ msg string
+ hard bool // survives a placeholder: a limit, an ambiguity
+}
+
+func (p problem) String() string { return p.msg }
+
+// ---- the package index ----
+
+type movesPkgIndex struct {
+ vars map[string]ast.Expr
+ funcs map[string]*ast.FuncDecl
+ methods map[string][]*ast.FuncDecl
+ types map[string]ast.Expr
+ decls []*ast.FuncDecl
+}
+
+var movesPkgCache = map[string]*movesPkgIndex{}
+
+// movesFset positions every file the scan parses, so a violation inside a
+// helper in a sibling file can be named file:line.
+var movesFset = token.NewFileSet()
+
+func newPkgIndex() *movesPkgIndex {
+ return &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{},
+ methods: map[string][]*ast.FuncDecl{}, types: map[string]ast.Expr{}}
+}
+
+func (p *movesPkgIndex) addFile(f *ast.File) {
+ for _, d := range f.Decls {
+ switch d := d.(type) {
+ case *ast.FuncDecl:
+ p.decls = append(p.decls, d)
+ if d.Recv == nil {
+ p.funcs[d.Name.Name] = d
+ } else {
+ p.methods[d.Name.Name] = append(p.methods[d.Name.Name], d)
+ }
+ case *ast.GenDecl:
+ for _, s := range d.Specs {
+ switch s := s.(type) {
+ case *ast.ValueSpec:
+ if len(s.Names) == len(s.Values) {
+ for i, nm := range s.Names {
+ p.vars[nm.Name] = s.Values[i]
+ }
+ }
+ case *ast.TypeSpec:
+ p.types[s.Name.Name] = s.Type
+ }
+ }
+ }
+ }
+}
+
+func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex {
+ t.Helper()
+ if p, ok := movesPkgCache[dir]; ok {
+ return p
+ }
+ p := newPkgIndex()
+ movesPkgCache[dir] = p
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range ents {
+ n := e.Name()
+ if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
+ continue
+ }
+ f, err := parser.ParseFile(movesFset, filepath.Join(dir, n), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.addFile(f)
+ }
+ return p
+}
+
+func recvBase(d *ast.FuncDecl) string {
+ if d.Recv == nil || len(d.Recv.List) == 0 {
+ return ""
+ }
+ return baseTypeName(d.Recv.List[0].Type)
+}
+
+func baseTypeName(e ast.Expr) string {
+ switch e := e.(type) {
+ case *ast.Ident:
+ return e.Name
+ case *ast.StarExpr:
+ return baseTypeName(e.X)
+ case *ast.ParenExpr:
+ return baseTypeName(e.X)
+ case *ast.SelectorExpr:
+ return baseTypeName(e.X) + "." + e.Sel.Name // another package's type: never a package method
+ }
+ return ""
+}
+
+func paramNames(ft *ast.FuncType) []string {
+ var out []string
+ if ft.Params == nil {
+ return out
+ }
+ for _, f := range ft.Params.List {
+ if len(f.Names) == 0 {
+ out = append(out, "_")
+ }
+ for _, n := range f.Names {
+ out = append(out, n.Name)
+ }
+ }
+ return out
+}
+
+func resultCount(d *ast.FuncDecl) int {
+ if d.Type.Results == nil {
+ return 0
+ }
+ n := 0
+ for _, f := range d.Type.Results.List {
+ if len(f.Names) == 0 {
+ n++
+ }
+ n += len(f.Names)
+ }
+ return n
+}
+
+// stringResults is the set of a function's result positions that are strings.
+func stringResults(d *ast.FuncDecl) map[int]bool {
+ out := map[int]bool{}
+ if d.Type.Results == nil {
+ return out
+ }
+ i := 0
+ for _, f := range d.Type.Results.List {
+ n := len(f.Names)
+ if n == 0 {
+ n = 1
+ }
+ if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" {
+ for k := 0; k < n; k++ {
+ out[i+k] = true
+ }
+ }
+ i += n
+ }
+ return out
+}
+
+func namedResults(d *ast.FuncDecl) []string {
+ var out []string
+ if d.Type.Results == nil {
+ return out
+ }
+ for _, f := range d.Type.Results.List {
+ for _, n := range f.Names {
+ out = append(out, n.Name)
+ }
+ }
+ return out
+}
+
+// ---- local assignments ----
+
+// localAssign is one assignment to a variable of a function: an expression, a
+// position of a multi-value call, a zero value (a declaration with no value),
+// or something the gate cannot read (a range variable).
+type localAssign struct {
+ rhs ast.Expr
+ call *ast.CallExpr
+ pos int
+ zero bool
+ unreadable bool
+ typ ast.Expr // a declared type, when there is one
+ typeFrom ast.Expr // for an unreadable value, the expression whose type it has
+}
+
+func localAssigns(fd *ast.FuncDecl) map[string][]localAssign {
+ out := map[string][]localAssign{}
+ if fd == nil || fd.Body == nil {
+ return out
+ }
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.AssignStmt:
+ for i, l := range n.Lhs {
+ id, ok := l.(*ast.Ident)
+ if !ok || id.Name == "_" {
+ continue
+ }
+ switch {
+ case len(n.Rhs) == len(n.Lhs):
+ out[id.Name] = append(out[id.Name], localAssign{rhs: n.Rhs[i]})
+ case len(n.Rhs) == 1:
+ if c, ok := n.Rhs[0].(*ast.CallExpr); ok {
+ out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i})
+ } else if ix, ok := n.Rhs[0].(*ast.IndexExpr); ok && i == 0 {
+ out[id.Name] = append(out[id.Name], localAssign{unreadable: true, typeFrom: ix})
+ } else {
+ out[id.Name] = append(out[id.Name], localAssign{unreadable: true})
+ }
+ }
+ }
+ case *ast.ValueSpec:
+ for i, id := range n.Names {
+ switch {
+ case len(n.Values) == len(n.Names):
+ out[id.Name] = append(out[id.Name], localAssign{rhs: n.Values[i], typ: n.Type})
+ case len(n.Values) == 0:
+ out[id.Name] = append(out[id.Name], localAssign{zero: true, typ: n.Type})
+ case len(n.Values) == 1:
+ if c, ok := n.Values[0].(*ast.CallExpr); ok {
+ out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i, typ: n.Type})
+ } else {
+ out[id.Name] = append(out[id.Name], localAssign{unreadable: true})
+ }
+ }
+ }
+ case *ast.RangeStmt:
+ for _, x := range []ast.Expr{n.Key, n.Value} {
+ if id, ok := x.(*ast.Ident); ok && id.Name != "_" {
+ out[id.Name] = append(out[id.Name], localAssign{unreadable: true})
+ }
+ }
+ }
+ return true
+ })
+ return out
+}
+
+// ---- the universe: every struct field and every write to it ----
+
+type fieldWrite struct {
+ file string
+ fd *ast.FuncDecl
+ expr ast.Expr
+ ctype string // the composite literal's struct type, when it names one
+ recv ast.Expr // for an assignment, the expression whose field is written
+}
+
+type fieldResult struct {
+ done bool
+ probs []problem
+}
+
+type movesUniverse struct {
+ t *testing.T
+ root string
+ fieldWrites map[string][]*fieldWrite
+ fieldTypes map[string]map[string]bool
+ structFields map[string][]string
+ fieldTypeOf map[string]map[string]ast.Expr
+ results map[string]*fieldResult
+ used map[string]bool // package functions evaluated as sentence helpers
+ paramSinks map[string]bool // functions whose parameter reaches a sink
+ files []string
+ steps int
+}
+
+func (u *movesUniverse) at(p string) string {
+ if u.root == "." {
+ return p
+ }
+ return filepath.Join(u.root, p)
+}
+
+func newUniverse(t *testing.T, root string, files []string) *movesUniverse {
+ t.Helper()
+ u := &movesUniverse{t: t, root: root, fieldWrites: map[string][]*fieldWrite{}, fieldTypes: map[string]map[string]bool{},
+ structFields: map[string][]string{}, fieldTypeOf: map[string]map[string]ast.Expr{}, results: map[string]*fieldResult{}, used: map[string]bool{},
+ paramSinks: map[string]bool{}, files: files}
+ parsed := map[string]*ast.File{}
+ for _, f := range files {
+ af, err := parser.ParseFile(movesFset, u.at(f), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed[f] = af
+ }
+ addType := func(field, typ string) {
+ if u.fieldTypes[field] == nil {
+ u.fieldTypes[field] = map[string]bool{}
+ }
+ u.fieldTypes[field][typ] = true
+ }
+ structNames := map[*ast.StructType]string{}
+ for _, af := range parsed {
+ ast.Inspect(af, func(n ast.Node) bool {
+ if ts, ok := n.(*ast.TypeSpec); ok {
+ if st, ok := ts.Type.(*ast.StructType); ok {
+ structNames[st] = ts.Name.Name
+ }
+ }
+ return true
+ })
+ }
+ for _, af := range parsed {
+ ast.Inspect(af, func(n ast.Node) bool {
+ st, ok := n.(*ast.StructType)
+ if !ok {
+ return true
+ }
+ name, named := structNames[st]
+ if !named {
+ name = fmt.Sprintf("anonymous struct at %s", movesFset.Position(st.Pos()))
+ structNames[st] = name
+ }
+ var order []string
+ if u.fieldTypeOf[name] == nil {
+ u.fieldTypeOf[name] = map[string]ast.Expr{}
+ }
+ for _, f := range st.Fields.List {
+ for _, nm := range f.Names {
+ order = append(order, nm.Name)
+ addType(nm.Name, name)
+ u.fieldTypeOf[name][nm.Name] = f.Type
+ }
+ }
+ u.structFields[name] = order
+ return true
+ })
+ }
+ for _, f := range files {
+ af := parsed[f]
+ var fd *ast.FuncDecl
+ var visit func(n ast.Node) bool
+ visit = func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.FuncDecl:
+ fd = n
+ if n.Body != nil {
+ ast.Inspect(n.Body, visit)
+ }
+ fd = nil
+ return false
+ case *ast.CompositeLit:
+ var order []string
+ ctype := ""
+ switch ty := n.Type.(type) {
+ case *ast.Ident:
+ order = u.structFields[ty.Name]
+ ctype = ty.Name
+ case *ast.SelectorExpr:
+ order = u.structFields[ty.Sel.Name]
+ ctype = ty.Sel.Name
+ case *ast.StructType:
+ order = u.structFields[structNames[ty]]
+ ctype = structNames[ty]
+ }
+ for i, el := range n.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ if k, ok := kv.Key.(*ast.Ident); ok {
+ u.fieldWrites[k.Name] = append(u.fieldWrites[k.Name], &fieldWrite{f, fd, kv.Value, ctype, nil})
+ }
+ } else if i < len(order) {
+ u.fieldWrites[order[i]] = append(u.fieldWrites[order[i]], &fieldWrite{f, fd, el, ctype, nil})
+ }
+ }
+ case *ast.AssignStmt:
+ for i, l := range n.Lhs {
+ if s, ok := l.(*ast.SelectorExpr); ok && i < len(n.Rhs) && len(n.Rhs) == len(n.Lhs) {
+ u.fieldWrites[s.Sel.Name] = append(u.fieldWrites[s.Sel.Name], &fieldWrite{f, fd, n.Rhs[i], "", s.X})
+ }
+ }
+ }
+ return true
+ }
+ ast.Inspect(af, visit)
+ }
+ return u
+}
+
+var movesDefaultUniverse *movesUniverse
+
+// universeFor picks the universe a file is read in: the real tree's scanned
+// files, or just the file itself (a fixture, or a base-commit copy).
+func universeFor(t *testing.T, root, file string) *movesUniverse {
+ t.Helper()
+ if root == "." {
+ for _, f := range movesScanFiles {
+ if f == file {
+ if movesDefaultUniverse == nil {
+ movesDefaultUniverse = newUniverse(t, ".", movesScanFiles)
+ }
+ return movesDefaultUniverse
+ }
+ }
+ }
+ return newUniverse(t, root, []string{file})
+}
+
+// ---- evaluation ----
+
+type evalCtx struct {
+ u *movesUniverse
+ file string
+ fd *ast.FuncDecl
+ pkg *movesPkgIndex
+ locals map[string][]localAssign
+ bind map[string][]string
+ fwd map[string]bool
+ fwdFn map[string]bool
+ curReturn *ast.ReturnStmt
+ noCalls bool
+ visiting map[string]bool
+}
+
+func (u *movesUniverse) newCtx(file string, fd *ast.FuncDecl, noCalls bool) *evalCtx {
+ c := &evalCtx{u: u, file: file, fd: fd, locals: localAssigns(fd), bind: map[string][]string{},
+ fwd: map[string]bool{}, fwdFn: map[string]bool{}, noCalls: noCalls, visiting: map[string]bool{}}
+ c.pkg = loadMovesPkg(u.t, u.at(filepath.Dir(file)))
+ if fd == nil {
+ return c
+ }
+ if i, ok := movesLocalSinks[file][fd.Name.Name]; ok {
+ if ps := paramNames(fd.Type); i < len(ps) {
+ c.fwd[ps[i]] = true
+ }
+ }
+ if i, ok := movesFuncSinks[file][fd.Name.Name]; ok {
+ if ps := paramNames(fd.Type); i < len(ps) {
+ c.fwdFn[ps[i]] = true
+ }
+ }
+ // A closure that is a local sink (fail := func(why string)) forwards its parameter.
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ as, ok := n.(*ast.AssignStmt)
+ if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
+ return true
+ }
+ id, ok := as.Lhs[0].(*ast.Ident)
+ lit, isLit := as.Rhs[0].(*ast.FuncLit)
+ if !ok || !isLit {
+ return true
+ }
+ if i, ok := movesLocalSinks[file][id.Name]; ok {
+ if ps := paramNames(lit.Type); i < len(ps) {
+ c.fwd[ps[i]] = true
+ }
+ }
+ return true
+ })
+ return c
+}
+
+func (c *evalCtx) posOf(n ast.Node) string { return movesFset.Position(n.Pos()).String() }
+
+func hardOnly(ps []problem) []problem {
+ var out []problem
+ for _, p := range ps {
+ if p.hard {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func limit(c *evalCtx, n ast.Node, what string) []problem {
+ return []problem{{fmt.Sprintf("analysis limit reached at %s: %s", c.posOf(n), what), true}}
+}
+
+func cross(l, r []string) ([]string, bool) {
+ if len(l)*len(r) > maxAlts {
+ return nil, false
+ }
+ out := make([]string, 0, len(l)*len(r))
+ for _, a := range l {
+ for _, b := range r {
+ out = append(out, a+b)
+ }
+ }
+ return out, true
+}
+
+func dedupe(in []string) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, s := range in {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+// operand reads a placeholder: any value is allowed, so what it cannot derive
+// becomes one wildcard, which the record must bind to a <placeholder>. Only a
+// limit or a cycle (a hard problem) still counts.
+func (c *evalCtx) operand(e ast.Expr, depth int) ([]string, []problem) {
+ if sel, isField := e.(*ast.SelectorExpr); isField && !c.noCalls {
+ // A field read as a placeholder is a wildcard for the record's
+ // <placeholder>, except a field that carries a sentence from one function
+ // to another (a Check's Reason): that is forwarded when every write to it
+ // is a sentence.
+ if movesSentenceFields[sel.Sel.Name] {
+ if a, p := c.fieldRead(sel); len(p) == 0 {
+ return a, nil
+ }
+ }
+ return []string{wild}, nil
+ }
+ alts, probs := c.eval(e, depth)
+ if len(probs) == 0 {
+ return alts, nil
+ }
+ return []string{wild}, hardOnly(probs)
+}
+
+// eval returns the texts e can be and why it cannot be read.
+func (c *evalCtx) eval(e ast.Expr, depth int) ([]string, []problem) {
+ if depth > maxEvalDepth {
+ return nil, limit(c, e, "expression nested too deeply")
+ }
+ if c.u.steps++; c.u.steps > maxEvalSteps {
+ return nil, limit(c, e, "too much work reading this value")
+ }
+ soft := func(format string, a ...any) ([]string, []problem) {
+ return nil, []problem{{fmt.Sprintf(format, a...), false}}
+ }
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return c.eval(e.X, depth)
+ case *ast.BasicLit:
+ if e.Kind != token.STRING {
+ return soft("%s is not a string", e.Value)
+ }
+ v, err := strconv.Unquote(e.Value)
+ if err != nil {
+ return soft("%s is not a string literal", e.Value)
+ }
+ return []string{v}, nil
+ case *ast.Ident:
+ return c.evalIdent(e, depth)
+ case *ast.BinaryExpr:
+ if e.Op != token.ADD {
+ return soft("%s is not a sentence", render(e))
+ }
+ l, lp := c.operand(e.X, depth+1)
+ r, rp := c.operand(e.Y, depth+1)
+ if len(lp)+len(rp) > 0 {
+ return nil, append(lp, rp...)
+ }
+ out, ok := cross(l, r)
+ if !ok {
+ return nil, limit(c, e, "too many alternative sentences")
+ }
+ return dedupe(out), nil
+ case *ast.SelectorExpr:
+ if c.noCalls {
+ return []string{wild}, nil
+ }
+ return c.fieldRead(e)
+ case *ast.CallExpr:
+ return c.evalCall(e, 0, depth)
+ }
+ return soft("%s is not a sentence the gate can read", render(e))
+}
+
+func (c *evalCtx) evalIdent(id *ast.Ident, depth int) ([]string, []problem) {
+ if depth > maxEvalDepth {
+ return nil, limit(c, id, "alias chain too long")
+ }
+ if c.guardedEcho(id) {
+ return []string{fwdMark}, nil
+ }
+ var alts []string
+ var probs []problem
+ known := false
+ if b, ok := c.bind[id.Name]; ok {
+ known = true
+ alts = append(alts, b...)
+ } else if c.fwd[id.Name] {
+ known = true
+ alts = append(alts, fwdMark)
+ }
+ if as, ok := c.locals[id.Name]; ok {
+ known = true
+ key := fmt.Sprintf("local %p %s", c.fd, id.Name)
+ if c.visiting[key] {
+ return nil, []problem{{fmt.Sprintf("%s at %s is assigned from itself", id.Name, c.posOf(id)), true}}
+ }
+ c.visiting[key] = true
+ for _, a := range as {
+ switch {
+ case a.zero:
+ alts = append(alts, "")
+ case a.rhs != nil:
+ x, p := c.eval(a.rhs, depth+1)
+ alts, probs = append(alts, x...), append(probs, p...)
+ case a.call != nil:
+ x, p := c.evalCall(a.call, a.pos, depth+1)
+ alts, probs = append(alts, x...), append(probs, p...)
+ default:
+ probs = append(probs, problem{fmt.Sprintf("%s is assigned a value the gate cannot read (a range variable or a destructured value)", id.Name), false})
+ }
+ }
+ delete(c.visiting, key)
+ }
+ if known {
+ if len(alts) > maxAlts {
+ return nil, limit(c, id, "too many alternative sentences")
+ }
+ return dedupe(alts), probs
+ }
+ if x, ok := c.pkg.vars[id.Name]; ok {
+ pc := c.u.newCtx(c.file, nil, c.noCalls)
+ pc.visiting = c.visiting
+ key := "pkgvar " + id.Name
+ if c.visiting[key] {
+ return nil, []problem{{fmt.Sprintf("%s is defined in terms of itself", id.Name), true}}
+ }
+ c.visiting[key] = true
+ defer delete(c.visiting, key)
+ return pc.eval(x, depth+1)
+ }
+ if c.fd != nil {
+ for _, n := range paramNames(c.fd.Type) {
+ if n == id.Name {
+ return nil, []problem{{fmt.Sprintf("parameter %s is not bound to an argument (%s)", id.Name, c.posOf(id)), false}}
+ }
+ }
+ }
+ return nil, []problem{{fmt.Sprintf("%s is a variable the gate cannot read", id.Name), false}}
+}
+
+// fieldRead reads X.F: a sentence only if every write to a field named F, in
+// any struct of the scanned files (a keyed or positional composite literal, or
+// an assignment), is. The text of each write is checked where it is written,
+// so the read is forwarded.
+func (c *evalCtx) fieldRead(e *ast.SelectorExpr) ([]string, []problem) {
+ f := e.Sel.Name
+ types := c.u.fieldTypes[f]
+ if len(types) == 0 {
+ return nil, []problem{{fmt.Sprintf("%s: no struct in the scanned files declares a field %s", render(e), f), false}}
+ }
+ rt := lastName(baseTypeName(c.typeExprOf(e.X, 0)))
+ key := f + "|" + rt
+ res := c.u.results[key]
+ if res == nil {
+ res = &fieldResult{}
+ c.u.results[key] = res
+ n := 0
+ for _, w := range c.u.fieldWrites[f] {
+ wc := c.u.newCtx(w.file, w.fd, c.noCalls)
+ // A write whose struct type is known and is not the one read is another
+ // field that happens to share the name.
+ wt := lastName(w.ctype)
+ if w.recv != nil {
+ wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0)))
+ }
+ if rt != "" && wt != "" && wt != rt {
+ continue
+ }
+ n++
+ alts, probs := wc.eval(w.expr, 0)
+ for _, p := range probs {
+ res.probs = append(res.probs, problem{fmt.Sprintf("a write to field %s at %s is not a sentence: %s", f, wc.posOf(w.expr), p.msg), p.hard})
+ }
+ for _, a := range alts {
+ if a != "" && !strings.Contains(a, fwdMark) && !informative(a) {
+ res.probs = append(res.probs, problem{fmt.Sprintf("a write to field %s at %s has no text of its own (%q)", f, wc.posOf(w.expr), strings.ReplaceAll(a, wild, "%")), false})
+ }
+ }
+ }
+ if n == 0 {
+ res.probs = append(res.probs, problem{fmt.Sprintf("field %s is never written in the scanned files", f), false})
+ }
+ }
+ if len(res.probs) > 0 {
+ return nil, res.probs
+ }
+ return []string{fwdMark}, nil
+}
+
+func lastName(s string) string {
+ if i := strings.LastIndex(s, "."); i >= 0 {
+ return s[i+1:]
+ }
+ return s
+}
+
+func isFormatCall(e *ast.CallExpr) bool {
+ sel, ok := e.Fun.(*ast.SelectorExpr)
+ if !ok || len(e.Args) == 0 {
+ return false
+ }
+ id, ok := sel.X.(*ast.Ident)
+ return ok && ((id.Name == "fmt" && (sel.Sel.Name == "Sprintf" || sel.Sel.Name == "Errorf")) ||
+ (id.Name == "errors" && sel.Sel.Name == "New"))
+}
+
+// evalCall reads result pos of a call.
+func (c *evalCtx) evalCall(e *ast.CallExpr, pos, depth int) ([]string, []problem) {
+ if depth > maxEvalDepth {
+ return nil, limit(c, e, "call nesting too deep")
+ }
+ soft := func(format string, a ...any) ([]string, []problem) {
+ return nil, []problem{{fmt.Sprintf(format, a...), false}}
+ }
+ if isFormatCall(e) {
+ fmts, probs := c.eval(e.Args[0], depth+1)
+ if len(probs) > 0 {
+ return nil, append([]problem{{fmt.Sprintf("%s has a format that is not a literal", render(e)), false}}, probs...)
+ }
+ var out []string
+ for _, f := range fmts {
+ if strings.Contains(f, wild) || strings.Contains(f, fwdMark) {
+ return soft("%s has a format that is not a literal", render(e))
+ }
+ out = append(out, formatPattern(f))
+ }
+ return dedupe(out), nil
+ }
+ if c.noCalls {
+ return []string{wild}, nil
+ }
+ if sel, ok := e.Fun.(*ast.SelectorExpr); ok && len(e.Args) == 2 {
+ if x, ok := sel.X.(*ast.Ident); ok && x.Name == "strings" && sel.Sel.Name == "TrimPrefix" {
+ return c.evalTrimPrefix(e, depth)
+ }
+ }
+ decls, prob := c.resolveCallees(e)
+ if prob != nil {
+ return nil, []problem{*prob}
+ }
+ if len(decls) == 1 && c.fwdFn[renderName(e.Fun)] {
+ return []string{fwdMark}, nil
+ }
+ var alts []string
+ var probs []problem
+ for _, d := range decls {
+ a, p := c.callDecl(d, e, pos, depth)
+ alts, probs = append(alts, a...), append(probs, p...)
+ }
+ if len(alts) > maxAlts {
+ return nil, limit(c, e, "too many alternative sentences")
+ }
+ return dedupe(alts), probs
+}
+
+func renderName(e ast.Expr) string {
+ if id, ok := e.(*ast.Ident); ok {
+ return id.Name
+ }
+ return ""
+}
+
+// resolveCallees finds the package function or methods a call reaches, by
+// name for a function and by the receiver's declared type for a method.
+func (c *evalCtx) resolveCallees(e *ast.CallExpr) ([]*ast.FuncDecl, *problem) {
+ bad := func(format string, a ...any) ([]*ast.FuncDecl, *problem) {
+ return nil, &problem{fmt.Sprintf(format, a...), false}
+ }
+ switch fn := e.Fun.(type) {
+ case *ast.Ident:
+ _, isLocal := c.locals[fn.Name]
+ isParam := false
+ if c.fd != nil {
+ for _, p := range paramNames(c.fd.Type) {
+ if p == fn.Name {
+ isParam = true
+ }
+ }
+ }
+ d, isPkg := c.pkg.funcs[fn.Name]
+ switch {
+ case (isLocal || isParam) && isPkg:
+ return bad("%s at %s names both a local function value and a package function", fn.Name, c.posOf(fn))
+ case c.fwdFn[fn.Name]:
+ return []*ast.FuncDecl{{Name: fn}}, nil
+ case isLocal || isParam:
+ return bad("%s(...) calls a function value the gate cannot read", fn.Name)
+ case isPkg:
+ return []*ast.FuncDecl{d}, nil
+ }
+ return bad("%s(...) is not a function of this package", fn.Name)
+ case *ast.SelectorExpr:
+ if fn.Sel.Name == "Error" && len(e.Args) == 0 {
+ return bad("%s forwards an error's text", render(e))
+ }
+ tx := c.typeExprOf(fn.X, 0)
+ t := baseTypeName(tx)
+ if t == "" {
+ return bad("%s calls out of the package or on a value the gate cannot type", render(e))
+ }
+ if _, iface := c.pkg.types[t].(*ast.InterfaceType); iface {
+ return bad("%s at %s is a call through the interface %s, whose implementation the gate cannot see", render(e), c.posOf(e), t)
+ }
+ var out []*ast.FuncDecl
+ for _, d := range c.pkg.methods[fn.Sel.Name] {
+ if recvBase(d) == t {
+ out = append(out, d)
+ }
+ }
+ if len(out) == 0 {
+ return bad("%s: no method %s on %s in this package", render(e), fn.Sel.Name, t)
+ }
+ return out, nil
+ }
+ return bad("%s is not a call the gate can read", render(e))
+}
+
+// evalTrimPrefix reads strings.TrimPrefix(x, "lit"): the sentences x can be,
+// with the prefix taken off those that have it.
+func (c *evalCtx) evalTrimPrefix(e *ast.CallExpr, depth int) ([]string, []problem) {
+ xs, xp := c.eval(e.Args[0], depth+1)
+ ps, pp := c.eval(e.Args[1], depth+1)
+ if len(xp)+len(pp) > 0 {
+ return nil, append(xp, pp...)
+ }
+ var out []string
+ for _, x := range xs {
+ for _, p := range ps {
+ if strings.Contains(p, wild) || strings.Contains(p, fwdMark) {
+ return nil, []problem{{fmt.Sprintf("%s trims a prefix that is not a literal", render(e)), false}}
+ }
+ out = append(out, strings.TrimPrefix(x, p))
+ }
+ }
+ return dedupe(out), nil
+}
+
+func unstar(e ast.Expr) ast.Expr {
+ for {
+ switch x := e.(type) {
+ case *ast.StarExpr:
+ e = x.X
+ case *ast.ParenExpr:
+ e = x.X
+ default:
+ return e
+ }
+ }
+}
+
+// resultTypeAt is the declared type of result pos of d.
+func resultTypeAt(d *ast.FuncDecl, pos int) ast.Expr {
+ if d.Type.Results == nil {
+ return nil
+ }
+ i := 0
+ for _, f := range d.Type.Results.List {
+ n := len(f.Names)
+ if n == 0 {
+ n = 1
+ }
+ if pos < i+n {
+ return f.Type
+ }
+ i += n
+ }
+ return nil
+}
+
+// typeExprOf finds the declared type of an expression, as far as the source
+// says: a receiver, parameter or typed variable, a composite literal, a field
+// of a known struct, an element of a known map or slice, a call's result.
+func (c *evalCtx) typeExprOf(e ast.Expr, depth int) ast.Expr {
+ if depth > 6 {
+ return nil
+ }
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return c.typeExprOf(e.X, depth+1)
+ case *ast.UnaryExpr:
+ if e.Op == token.AND {
+ return c.typeExprOf(e.X, depth+1)
+ }
+ case *ast.CompositeLit:
+ return e.Type
+ case *ast.Ident:
+ if c.fd != nil {
+ if c.fd.Recv != nil {
+ for _, f := range c.fd.Recv.List {
+ for _, n := range f.Names {
+ if n.Name == e.Name {
+ return f.Type
+ }
+ }
+ }
+ }
+ if c.fd.Type.Params != nil {
+ for _, f := range c.fd.Type.Params.List {
+ for _, n := range f.Names {
+ if n.Name == e.Name {
+ return f.Type
+ }
+ }
+ }
+ }
+ }
+ for _, a := range c.locals[e.Name] {
+ switch {
+ case a.typ != nil:
+ return a.typ
+ case a.call != nil:
+ if decls, p := c.resolveCallees(a.call); p == nil && len(decls) > 0 && decls[0].Type != nil {
+ if tx := resultTypeAt(decls[0], a.pos); tx != nil {
+ return tx
+ }
+ }
+ case a.rhs != nil:
+ if tx := c.typeExprOf(a.rhs, depth+1); tx != nil {
+ return tx
+ }
+ case a.typeFrom != nil:
+ if tx := c.typeExprOf(a.typeFrom, depth+1); tx != nil {
+ return tx
+ }
+ }
+ }
+ case *ast.SelectorExpr:
+ base := lastName(baseTypeName(c.typeExprOf(e.X, depth+1)))
+ if ft, ok := c.u.fieldTypeOf[base][e.Sel.Name]; ok {
+ return ft
+ }
+ case *ast.IndexExpr:
+ switch x := unstar(c.typeExprOf(e.X, depth+1)).(type) {
+ case *ast.MapType:
+ return x.Value
+ case *ast.ArrayType:
+ return x.Elt
+ }
+ case *ast.CallExpr:
+ if decls, p := c.resolveCallees(e); p == nil && len(decls) > 0 && decls[0].Type != nil {
+ return resultTypeAt(decls[0], 0)
+ }
+ }
+ return nil
+}
+
+// callDecl evaluates result pos of d called as call: its parameters bound to
+// the arguments (read as placeholders), then every return.
+func (c *evalCtx) callDecl(d *ast.FuncDecl, call *ast.CallExpr, pos, depth int) ([]string, []problem) {
+ if d.Body == nil || !stringResults(d)[pos] {
+ return nil, []problem{{fmt.Sprintf("%s(...) does not return a sentence at result %d", d.Name.Name, pos), false}}
+ }
+ key := fmt.Sprintf("helper %p %d", d, pos)
+ if c.visiting[key] {
+ return nil, []problem{{fmt.Sprintf("%s calls itself at %s", d.Name.Name, c.posOf(call)), true}}
+ }
+ c.visiting[key] = true
+ defer delete(c.visiting, key)
+ c.u.used[d.Name.Name] = true
+
+ dfile := strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/")
+ hc := c.u.newCtx(dfile, d, c.noCalls)
+ hc.visiting = c.visiting
+ params := paramNames(d.Type)
+ var probs []problem
+ for i, p := range params {
+ if i < len(call.Args) {
+ a, pr := c.operand(call.Args[i], depth+1)
+ hc.bind[p] = a
+ probs = append(probs, pr...)
+ } else {
+ hc.bind[p] = []string{wild}
+ }
+ }
+ nres := resultCount(d)
+ named := namedResults(d)
+ var alts []string
+ ast.Inspect(d.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.FuncLit:
+ return false
+ case *ast.ReturnStmt:
+ hc.curReturn = n
+ var a []string
+ var p []problem
+ switch {
+ case len(n.Results) == nres && pos < len(n.Results):
+ a, p = hc.eval(n.Results[pos], depth+1)
+ case len(n.Results) == 1 && nres > 1:
+ if cl, ok := n.Results[0].(*ast.CallExpr); ok {
+ a, p = hc.evalCall(cl, pos, depth+1)
+ }
+ case len(n.Results) == 0 && pos < len(named):
+ a, p = hc.evalIdent(&ast.Ident{Name: named[pos], NamePos: n.Pos()}, depth+1)
+ default:
+ p = []problem{{fmt.Sprintf("return at %s has a shape the gate cannot read", hc.posOf(n)), false}}
+ }
+ hc.curReturn = nil
+ for _, x := range p {
+ probs = append(probs, problem{fmt.Sprintf("helper %s, at %s: %s", d.Name.Name, hc.posOf(n), x.msg), x.hard})
+ }
+ alts = append(alts, a...)
+ }
+ return true
+ })
+ return alts, probs
+}
+
+// ---- guarded echo (a mapping helper) ----
+
+func (c *evalCtx) guardedEcho(id *ast.Ident) bool {
+ if c.curReturn == nil || c.fd == nil || !movesMappingHelpers[c.file][c.fd.Name.Name] {
+ return false
+ }
+ direct := false
+ for _, r := range c.curReturn.Results {
+ if r == ast.Expr(id) {
+ direct = true
+ }
+ }
+ if !direct {
+ return false
+ }
+ var cc *ast.CaseClause
+ var stack []ast.Node
+ ast.Inspect(c.fd.Body, func(n ast.Node) bool {
+ if n == nil {
+ stack = stack[:len(stack)-1]
+ return true
+ }
+ stack = append(stack, n)
+ if n == ast.Node(c.curReturn) {
+ for i := len(stack) - 1; i >= 0; i-- {
+ if k, ok := stack[i].(*ast.CaseClause); ok {
+ cc = k
+ break
+ }
+ }
+ }
+ return cc == nil
+ })
+ if cc == nil || len(cc.List) == 0 {
+ return false
+ }
+ for _, g := range cc.List {
+ if !guardsLiteral(g, id.Name, c) {
+ return false
+ }
+ }
+ return true
+}
+
+func (c *evalCtx) isLiteralText(e ast.Expr) bool {
+ switch e := e.(type) {
+ case *ast.ParenExpr:
+ return c.isLiteralText(e.X)
+ case *ast.BasicLit:
+ return e.Kind == token.STRING
+ case *ast.Ident:
+ if _, local := c.locals[e.Name]; local {
+ return false
+ }
+ _, ok := c.pkg.vars[e.Name]
+ return ok
+ }
+ return false
+}
+
+func guardsLiteral(g ast.Expr, name string, c *evalCtx) bool {
+ switch g := g.(type) {
+ case *ast.ParenExpr:
+ return guardsLiteral(g.X, name, c)
+ case *ast.BinaryExpr:
+ switch g.Op {
+ case token.LAND:
+ return guardsLiteral(g.X, name, c) && guardsLiteral(g.Y, name, c)
+ case token.EQL:
+ l, lok := g.X.(*ast.Ident)
+ r, rok := g.Y.(*ast.Ident)
+ switch {
+ case lok && l.Name == name:
+ return c.isLiteralText(g.Y) && !(rok && r.Name == name)
+ case rok && r.Name == name:
+ return c.isLiteralText(g.X)
+ }
+ }
+ case *ast.CallExpr:
+ sel, ok := g.Fun.(*ast.SelectorExpr)
+ if !ok || len(g.Args) != 2 {
+ return false
+ }
+ x, isID := sel.X.(*ast.Ident)
+ a0, a0ok := g.Args[0].(*ast.Ident)
+ if !isID || x.Name != "strings" || !a0ok || a0.Name != name {
+ return false
+ }
+ switch sel.Sel.Name {
+ case "HasPrefix", "HasSuffix", "Contains":
+ return c.isLiteralText(g.Args[1])
+ }
+ }
+ return false
+}
+
+func render(e ast.Expr) string {
+ var b strings.Builder
+ ast.Inspect(e, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.BinaryExpr:
+ b.WriteString(render(n.X) + " " + n.Op.String() + " " + render(n.Y))
+ return false
+ case *ast.Ident:
+ b.WriteString(n.Name)
+ case *ast.SelectorExpr:
+ b.WriteString(render(n.X) + "." + n.Sel.Name)
+ return false
+ case *ast.CallExpr:
+ b.WriteString(render(n.Fun) + "(...)")
+ return false
+ }
+ return true
+ })
+ return b.String()
+}
+
+// ---- emissions and violations ----
+
+type movesEmission struct {
+ file string
+ line int // where the call or literal starts
+ endLine int
+ fn string // the enclosing function
+ kind string
+ pattern string
+ fragment bool // a piece of a sentence, matched by containment
+ underived bool // the gate could not derive a sentence: it matches no row
+}
+
+func (e movesEmission) String() string {
+ if e.underived {
+ return fmt.Sprintf("%s:%d (%s) a value the gate could not derive to a sentence", e.file, e.line, e.kind)
+ }
+ return fmt.Sprintf("%s:%d (%s) %q", e.file, e.line, e.kind, strings.ReplaceAll(e.pattern, wild, "%"))
+}
+
+// informative reports whether a pattern says anything: at least three letters
+// outside the parts the code fills in (a bare "a " or "S2: " is a piece of
+// a sentence, not one).
+func informative(p string) bool {
+ n := 0
+ for _, r := range strings.ReplaceAll(strings.ReplaceAll(p, wild, ""), fwdMark, "") {
+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
+ if n++; n == 3 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// movesViolation is a sink that does not take a sentence the gate can derive.
+type movesViolation struct {
+ file string
+ line int
+ fn string
+ kind string
+ why string
+}
+
+func (v movesViolation) String() string {
+ return fmt.Sprintf("%s:%d (%s in %s): %s", v.file, v.line, v.kind, v.fn, v.why)
+}
+
+// scanMovesFile returns the emissions of one file.
+func scanMovesFile(t *testing.T, file string) []movesEmission {
+ t.Helper()
+ return scanMovesFileWith(t, ".", file, false)
+}
+
+// scanMovesFileWith is scanMovesFile; noCalls reads calls to package
+// functions and field reads as wildcards, so a legacy function's sentence is
+// only the text written in that function.
+func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmission {
+ t.Helper()
+ ems, _ := scanMovesFileFull(t, root, file, noCalls)
+ return ems
+}
+
+func mentions(e ast.Expr, locals map[string][]localAssign, params map[string]bool, out map[string]bool, depth int) {
+ ast.Inspect(e, func(n ast.Node) bool {
+ if id, ok := n.(*ast.Ident); ok {
+ if params[id.Name] {
+ out[id.Name] = true
+ }
+ if depth < 3 {
+ for _, a := range locals[id.Name] {
+ if a.rhs != nil {
+ mentions(a.rhs, locals, params, out, depth+1)
+ }
+ if a.call != nil {
+ mentions(a.call, locals, params, out, depth+1)
+ }
+ }
+ }
+ }
+ return true
+ })
+}
+
+// scanMovesFileFull returns a file's emissions and its sink violations.
+func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEmission, []movesViolation) {
+ t.Helper()
+ return scanMovesFileIn(t, universeFor(t, root, file), root, file, noCalls)
+}
+
+// scanMovesFileIn scans a file in the given universe of struct fields.
+func scanMovesFileIn(t *testing.T, u *movesUniverse, root, file string, noCalls bool) ([]movesEmission, []movesViolation) {
+ t.Helper()
+ at := func(p string) string {
+ if root == "." {
+ return p
+ }
+ return filepath.Join(root, p)
+ }
+ fset := movesFset
+ f, err := parser.ParseFile(fset, at(file), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []movesEmission
+ var vios []movesViolation
+ seenVio := map[string]bool{}
+ var fn string
+ var ctx *evalCtx
+ violate := func(line int, kind, why string) {
+ v := movesViolation{file, line, fn, kind, why}
+ if !seenVio[v.String()] {
+ seenVio[v.String()] = true
+ vios = append(vios, v)
+ }
+ }
+ emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) {
+ line := fset.Position(pos).Line
+ end := fset.Position(e.End()).Line
+ alts, probs := ctx.eval(e, 0)
+ if ctx.fd != nil {
+ params := map[string]bool{}
+ if ctx.fd.Type.Params != nil {
+ for _, f := range ctx.fd.Type.Params.List {
+ if id, ok := f.Type.(*ast.Ident); ok && (id.Name == "string" || id.Name == "error") {
+ for _, n := range f.Names {
+ params[n.Name] = true
+ }
+ }
+ }
+ }
+ hit := map[string]bool{}
+ mentions(e, ctx.locals, params, hit, 0)
+ if len(hit) > 0 {
+ u.paramSinks[ctx.fd.Name.Name] = true
+ }
+ }
+ underived := len(probs) > 0
+ for _, p := range probs {
+ violate(line, kind, p.msg)
+ }
+ for _, a := range dedupe(alts) {
+ switch {
+ case a == "":
+ case strings.Contains(a, fwdMark) && !informative(a):
+ // forwarded text with no sentence of its own around it: checked where it is made
+ case informative(a):
+ out = append(out, movesEmission{file, line, end, fn, kind, strings.ReplaceAll(a, fwdMark, wild), fragment, false})
+ default:
+ underived = true
+ if !fragment {
+ violate(line, kind, fmt.Sprintf("a value with no text of its own reaches this sink (it derives only to %q)", strings.ReplaceAll(a, wild, "%")))
+ }
+ }
+ }
+ if underived && !fragment {
+ out = append(out, movesEmission{file, line, end, fn, kind, "", false, true})
+ }
+ }
+ isCheck := func(x ast.Expr) bool {
+ switch x := x.(type) {
+ case *ast.Ident:
+ return x.Name == "Check"
+ case *ast.SelectorExpr:
+ return x.Sel.Name == "Check"
+ }
+ return false
+ }
+ tables := map[string]bool{}
+ for _, n := range movesCopyTables[file] {
+ tables[n] = true
+ }
+ literals := map[string]bool{}
+ for _, n := range movesLiteralFuncs[file] {
+ literals[n] = true
+ }
+ for _, d := range f.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+ fn = fd.Name.Name
+ ctx = u.newCtx(file, fd, noCalls)
+ inTable := tables[fn]
+ strs := stringResults(fd)
+ named := namedResults(fd)
+ returnsSentence := movesReturnedSentences[file][fn] && len(strs) > 0
+ var stack []ast.Node
+ litDepth := 0
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ if n == nil {
+ if _, ok := stack[len(stack)-1].(*ast.FuncLit); ok {
+ litDepth--
+ }
+ stack = stack[:len(stack)-1]
+ return true
+ }
+ stack = append(stack, n)
+ switch n := n.(type) {
+ case *ast.FuncLit:
+ litDepth++
+ case *ast.BasicLit:
+ // A literal with an em dash is main's wording, which phase1Conflict
+ // matches and rewrites; it is never spoken. A literal here is a
+ // piece of a sentence unless it is compared whole or returned.
+ if literals[fn] && n.Kind == token.STRING && !strings.Contains(n.Value, `\u2014`) && !strings.ContainsRune(n.Value, '\u2014') {
+ emit(n.Pos(), fn, n, true)
+ }
+ case *ast.CallExpr:
+ name := ""
+ switch f := n.Fun.(type) {
+ case *ast.Ident:
+ name = f.Name
+ case *ast.SelectorExpr:
+ name = f.Sel.Name // a method: only local sinks, below
+ }
+ i, sink := movesLocalSinks[file][name]
+ if id, ok := n.Fun.(*ast.Ident); ok && id.Name == "jsonError" {
+ i, sink = 2, true
+ }
+ if sink && i < len(n.Args) {
+ emit(n.Pos(), name, n.Args[i], false)
+ }
+ if j, fsink := movesFuncSinks[file][name]; fsink && j < len(n.Args) {
+ if why := ctx.checkFuncArg(n.Args[j]); why != "" {
+ violate(fset.Position(n.Pos()).Line, name, why)
+ }
+ }
+ case *ast.CompositeLit:
+ if n.Type != nil && isCheck(n.Type) {
+ for _, el := range n.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Reason" {
+ emit(kv.Pos(), "Check.Reason", kv.Value, false)
+ }
+ }
+ }
+ }
+ if inTable {
+ for _, el := range n.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ switch kv.Value.(type) {
+ case *ast.FuncLit, *ast.CompositeLit:
+ // closures are read through their returns; nested tables by descent
+ default:
+ emit(kv.Pos(), fn, kv.Value, false)
+ }
+ }
+ }
+ }
+ case *ast.AssignStmt:
+ if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
+ if s, ok := n.Lhs[0].(*ast.SelectorExpr); ok && s.Sel.Name == "Reason" && isCheck(s.X) {
+ emit(n.Pos(), "Check.Reason", n.Rhs[0], false)
+ }
+ }
+ case *ast.ReturnStmt:
+ ctx.curReturn = n
+ switch {
+ case inTable:
+ for _, r := range n.Results {
+ if _, nested := r.(*ast.CompositeLit); !nested {
+ emit(r.Pos(), fn, r, false)
+ }
+ }
+ case literals[fn]:
+ // phase1Conflict's returns are sentences too.
+ for _, r := range n.Results {
+ emit(r.Pos(), fn, r, false)
+ }
+ case returnsSentence && litDepth == 0:
+ if len(n.Results) == 0 {
+ for i, nm := range named {
+ if strs[i] {
+ emit(n.Pos(), "returned sentence", &ast.Ident{Name: nm, NamePos: n.Pos()}, false)
+ }
+ }
+ }
+ for i, r := range n.Results {
+ if strs[i] {
+ emit(r.Pos(), "returned sentence", r, false)
+ }
+ }
+ }
+ ctx.curReturn = nil
+ }
+ return true
+ })
+ }
+ return out, vios
+}
+
+// checkFuncArg checks the function a call hands to a sentence-function
+// parameter: a package function that is a sentence helper.
+func (c *evalCtx) checkFuncArg(e ast.Expr) string {
+ id, ok := e.(*ast.Ident)
+ if !ok {
+ return fmt.Sprintf("%s is passed where a sentence helper is expected", render(e))
+ }
+ d, isFn := c.pkg.funcs[id.Name]
+ if !isFn {
+ return fmt.Sprintf("%s is passed where a sentence helper is expected", render(e))
+ }
+ call := &ast.CallExpr{Fun: id, Args: nil}
+ _, probs := c.callDecl(d, call, 0, 0)
+ for _, p := range probs {
+ return p.msg
+ }
+ return ""
+}
+
+// ---- function values ----
+
+// movesFuncValueAllow lists functions that take a sentence parameter and are
+// referenced other than as a direct call, each with the reason that is safe.
+// The file is where the reference is.
+var movesFuncValueAllow = map[string]map[string]string{
+ "internal/console/apiinstancemove.go": {
+ "phase1Conflict": "handed only to moveTransitionSaying's conflict parameter; movesFuncSinks makes the gate read every return of it at that call site, and its err parameter only selects among recorded sentences",
+ },
+}
+
+type funcRef struct {
+ file string
+ name string
+ msg string
+}
+
+// funcValueRefs finds references to sentence-flow functions that are not
+// direct calls: `f := send`, a method value, a function passed as an
+// argument. A call through such a value escapes the argument checks.
+func funcValueRefs(t *testing.T, u *movesUniverse) []funcRef {
+ t.Helper()
+ flow := map[string]bool{}
+ for n := range u.used {
+ flow[n] = true
+ }
+ for n := range u.paramSinks {
+ flow[n] = true
+ }
+ // Only names that are declared functions or methods of the scanned files.
+ declared := map[string]bool{}
+ var out []funcRef
+ for _, f := range u.files {
+ af, err := parser.ParseFile(movesFset, u.at(f), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range af.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok {
+ continue
+ }
+ declared[fd.Name.Name] = true
+ // The forwarders configured as sinks are sentence-flow functions too.
+ if _, ok := movesLocalSinks[f][fd.Name.Name]; ok {
+ flow[fd.Name.Name] = true
+ }
+ if _, ok := movesFuncSinks[f][fd.Name.Name]; ok {
+ flow[fd.Name.Name] = true
+ }
+ // Main's own functions are not the series' concern.
+ if _, ok := movesLegacyFiles[f]; ok {
+ delete(flow, fd.Name.Name)
+ } else if _, ok := movesLegacyFuncs[f][fd.Name.Name]; ok {
+ delete(flow, fd.Name.Name)
+ }
+ }
+ }
+ for _, f := range u.files {
+ af, err := parser.ParseFile(movesFset, u.at(f), nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ callFun := map[ast.Node]bool{}
+ skip := map[ast.Node]bool{}
+ ast.Inspect(af, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.CallExpr:
+ callFun[n.Fun] = true
+ case *ast.FuncDecl:
+ skip[n.Name] = true
+ case *ast.SelectorExpr:
+ skip[n.Sel] = true
+ if flow[n.Sel.Name] && declared[n.Sel.Name] && !callFun[n] {
+ out = append(out, funcRef{f, n.Sel.Name, fmt.Sprintf("%s: %s is used as a value, not called, so a call through it escapes the argument checks", movesFset.Position(n.Pos()), render(n))})
+ }
+ case *ast.KeyValueExpr:
+ if id, ok := n.Key.(*ast.Ident); ok {
+ skip[id] = true
+ }
+ case *ast.Ident:
+ if skip[n] || callFun[n] {
+ return true
+ }
+ if flow[n.Name] && declared[n.Name] {
+ out = append(out, funcRef{f, n.Name, fmt.Sprintf("%s: %s is used as a value, not called, so a call through it escapes the argument checks", movesFset.Position(n.Pos()), n.Name)})
+ }
+ }
+ return true
+ })
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].msg < out[j].msg })
+ return out
+}
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index cbd54f492..c104360e8 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -234,18 +234,13 @@ func movesTextApplied(file, text string, body []byte) bool {
if err != nil {
return false
}
- idx := &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}}
+ vars := map[string]ast.Expr{}
for _, d := range f.Decls {
- switch d := d.(type) {
- case *ast.FuncDecl:
- if d.Recv == nil {
- idx.funcs[d.Name.Name] = d
- }
- case *ast.GenDecl:
- for _, sp := range d.Specs {
+ if g, ok := d.(*ast.GenDecl); ok {
+ for _, sp := range g.Specs {
if vs, ok := sp.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) {
for i, nm := range vs.Names {
- idx.vars[nm.Name] = vs.Values[i]
+ vars[nm.Name] = vs.Values[i]
}
}
}
@@ -274,7 +269,7 @@ func movesTextApplied(file, text string, body []byte) bool {
if e == nil {
return true
}
- for _, p := range patternsOf(e, idx, 0) {
+ for _, p := range literalPatterns(e, vars, 0) {
if informative(p) && movesRowSpoken(text, p) {
found = true
}
@@ -675,505 +670,6 @@ func formatPattern(f string) string {
})
}
-// movesPkgIndex is one directory's package-level strings and functions, so a
-// Reason that names a constant or a one-line helper is read through.
-type movesPkgIndex struct {
- vars map[string]ast.Expr
- funcs map[string]*ast.FuncDecl
- methods map[string][]*ast.FuncDecl
- decls []*ast.FuncDecl
- noCalls bool // read a call to a package function as a wildcard
- // locals are the assignments to the variables of the function being read.
- locals map[string][]localAssign
-}
-
-var movesPkgCache = map[string]*movesPkgIndex{}
-
-// movesFset positions every file the scan parses, so a violation inside a
-// helper in a sibling file can be named file:line.
-var movesFset = token.NewFileSet()
-
-func newPkgIndex() *movesPkgIndex {
- return &movesPkgIndex{vars: map[string]ast.Expr{}, funcs: map[string]*ast.FuncDecl{}, methods: map[string][]*ast.FuncDecl{}}
-}
-
-func (p *movesPkgIndex) addFile(f *ast.File) {
- for _, d := range f.Decls {
- switch d := d.(type) {
- case *ast.FuncDecl:
- p.decls = append(p.decls, d)
- if d.Recv == nil {
- p.funcs[d.Name.Name] = d
- } else {
- p.methods[d.Name.Name] = append(p.methods[d.Name.Name], d)
- }
- case *ast.GenDecl:
- for _, s := range d.Specs {
- if vs, ok := s.(*ast.ValueSpec); ok && len(vs.Names) == len(vs.Values) {
- for i, nm := range vs.Names {
- p.vars[nm.Name] = vs.Values[i]
- }
- }
- }
- }
- }
-}
-
-func loadMovesPkg(t *testing.T, dir string) *movesPkgIndex {
- t.Helper()
- if p, ok := movesPkgCache[dir]; ok {
- return p
- }
- p := newPkgIndex()
- movesPkgCache[dir] = p
- ents, err := os.ReadDir(dir)
- if err != nil {
- t.Fatal(err)
- }
- for _, e := range ents {
- n := e.Name()
- if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
- continue
- }
- f, err := parser.ParseFile(movesFset, filepath.Join(dir, n), nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- p.addFile(f)
- }
- return p
-}
-
-// localAssign is one assignment to a local variable: either an expression
-// (rhs), or position pos of a multi-value call, or a zero value (a declaration
-// with no value), or something the gate cannot read (a range variable).
-type localAssign struct {
- rhs ast.Expr
- call *ast.CallExpr
- pos int
- zero bool
- unreadable bool
-}
-
-// localAssigns lists every assignment to every variable of fd, by name.
-func localAssigns(fd *ast.FuncDecl) map[string][]localAssign {
- out := map[string][]localAssign{}
- if fd == nil || fd.Body == nil {
- return out
- }
- ast.Inspect(fd.Body, func(n ast.Node) bool {
- switch n := n.(type) {
- case *ast.AssignStmt:
- for i, l := range n.Lhs {
- id, ok := l.(*ast.Ident)
- if !ok || id.Name == "_" {
- continue
- }
- switch {
- case len(n.Rhs) == len(n.Lhs):
- out[id.Name] = append(out[id.Name], localAssign{rhs: n.Rhs[i]})
- case len(n.Rhs) == 1:
- if c, ok := n.Rhs[0].(*ast.CallExpr); ok {
- out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i})
- } else {
- out[id.Name] = append(out[id.Name], localAssign{unreadable: true})
- }
- }
- }
- case *ast.ValueSpec:
- for i, id := range n.Names {
- switch {
- case len(n.Values) == len(n.Names):
- out[id.Name] = append(out[id.Name], localAssign{rhs: n.Values[i]})
- case len(n.Values) == 0:
- out[id.Name] = append(out[id.Name], localAssign{zero: true})
- case len(n.Values) == 1:
- if c, ok := n.Values[0].(*ast.CallExpr); ok {
- out[id.Name] = append(out[id.Name], localAssign{call: c, pos: i})
- }
- }
- }
- case *ast.RangeStmt:
- for _, x := range []ast.Expr{n.Key, n.Value} {
- if id, ok := x.(*ast.Ident); ok && id.Name != "_" {
- out[id.Name] = append(out[id.Name], localAssign{unreadable: true})
- }
- }
- }
- return true
- })
- return out
-}
-
-// calleeDecls finds the package function or methods a call may reach. A
-// selector is matched by method name only; the gate has no types.
-func calleeDecls(e *ast.CallExpr, pkg *movesPkgIndex) []*ast.FuncDecl {
- switch fn := e.Fun.(type) {
- case *ast.Ident:
- if d, ok := pkg.funcs[fn.Name]; ok {
- return []*ast.FuncDecl{d}
- }
- case *ast.SelectorExpr:
- if fn.Sel.Name == "Error" {
- return nil
- }
- return pkg.methods[fn.Sel.Name]
- }
- return nil
-}
-
-// patternsOf returns the sentence patterns an expression can produce:
-// literals, concatenations, fmt.Sprintf formats, package constants, local
-// variables (through every assignment), and package functions and methods
-// that return strings. Anything it cannot read is a wildcard.
-func patternsOf(e ast.Expr, pkg *movesPkgIndex, depth int) []string {
- if depth > 4 {
- return []string{wild}
- }
- switch e := e.(type) {
- case *ast.BasicLit:
- if e.Kind == token.STRING {
- if v, err := strconv.Unquote(e.Value); err == nil {
- return []string{v}
- }
- }
- case *ast.ParenExpr:
- return patternsOf(e.X, pkg, depth)
- case *ast.Ident:
- if as, ok := pkg.locals[e.Name]; ok {
- var out []string
- for _, a := range as {
- switch {
- case a.zero:
- out = append(out, "")
- case a.rhs != nil:
- out = append(out, patternsOf(a.rhs, pkg, depth+1)...)
- case a.call != nil:
- out = append(out, callPatterns(a.call, a.pos, pkg, depth+1)...)
- default:
- out = append(out, wild)
- }
- }
- return out
- }
- if x, ok := pkg.vars[e.Name]; ok {
- return patternsOf(x, pkg, depth+1)
- }
- case *ast.BinaryExpr:
- if e.Op != token.ADD {
- break
- }
- var out []string
- for _, l := range patternsOf(e.X, pkg, depth) {
- for _, r := range patternsOf(e.Y, pkg, depth) {
- out = append(out, l+r)
- }
- }
- return out
- case *ast.CallExpr:
- if isFormatCall(e) {
- var out []string
- for _, f := range patternsOf(e.Args[0], pkg, depth) {
- out = append(out, formatPattern(f))
- }
- return out
- }
- return callPatterns(e, 0, pkg, depth)
- }
- return []string{wild}
-}
-
-// callPatterns reads the sentences result pos of a call to a package function
-// or method can be: the patterns of its returns, read in the callee's own
-// locals.
-func callPatterns(e *ast.CallExpr, pos int, pkg *movesPkgIndex, depth int) []string {
- if pkg.noCalls || depth > 4 {
- return []string{wild}
- }
- decls := calleeDecls(e, pkg)
- if len(decls) == 0 {
- return []string{wild}
- }
- var out []string
- for _, d := range decls {
- if d.Body == nil || !stringResults(d)[pos] {
- out = append(out, wild)
- continue
- }
- hp := *pkg
- hp.locals = localAssigns(d)
- nres := resultCount(d)
- ast.Inspect(d.Body, func(n ast.Node) bool {
- switch n := n.(type) {
- case *ast.FuncLit:
- return false
- case *ast.ReturnStmt:
- switch {
- case len(n.Results) == nres && pos < len(n.Results):
- out = append(out, patternsOf(n.Results[pos], &hp, depth+1)...)
- case len(n.Results) == 1 && nres > 1:
- if c, ok := n.Results[0].(*ast.CallExpr); ok {
- out = append(out, callPatterns(c, pos, &hp, depth+1)...)
- }
- }
- }
- return true
- })
- }
- return out
-}
-
-func resultCount(d *ast.FuncDecl) int {
- if d.Type.Results == nil {
- return 0
- }
- n := 0
- for _, f := range d.Type.Results.List {
- if len(f.Names) == 0 {
- n++
- }
- n += len(f.Names)
- }
- return n
-}
-
-func returnsOneString(d *ast.FuncDecl) bool {
- r := d.Type.Results
- if r == nil || len(r.List) != 1 || len(r.List[0].Names) > 1 {
- return false
- }
- id, ok := r.List[0].Type.(*ast.Ident)
- return ok && id.Name == "string"
-}
-
-type movesEmission struct {
- file string
- line int // where the call or literal starts
- endLine int
- fn string // the enclosing function
- kind string
- pattern string
- fragment bool // a piece of a sentence, matched by containment
-}
-
-func (e movesEmission) String() string {
- return fmt.Sprintf("%s:%d (%s) %q", e.file, e.line, e.kind, strings.ReplaceAll(e.pattern, wild, "%"))
-}
-
-// informative reports whether a pattern says anything: at least three letters
-// outside the parts the code fills in (a bare "a " or "S2: " is a piece of
-// a sentence, not one).
-func informative(p string) bool {
- n := 0
- for _, r := range strings.ReplaceAll(p, wild, "") {
- if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
- if n++; n == 3 {
- return true
- }
- }
- }
- return false
-}
-
-// scanMovesFile returns the emissions of one file.
-func scanMovesFile(t *testing.T, file string) []movesEmission {
- t.Helper()
- return scanMovesFileWith(t, ".", file, false)
-}
-
-// scanMovesFileWith is scanMovesFile; noCalls reads calls to package
-// functions as wildcards, so a legacy function's sentence is only the text
-// written in that function.
-func scanMovesFileWith(t *testing.T, root, file string, noCalls bool) []movesEmission {
- t.Helper()
- ems, _ := scanMovesFileFull(t, root, file, noCalls)
- return ems
-}
-
-// movesViolation is a sink that does not take a sentence the gate can read.
-type movesViolation struct {
- file string
- line int
- fn string
- kind string
- why string
-}
-
-func (v movesViolation) String() string {
- return fmt.Sprintf("%s:%d (%s in %s): %s", v.file, v.line, v.kind, v.fn, v.why)
-}
-
-// scanMovesFileFull returns a file's emissions and its sink violations.
-func scanMovesFileFull(t *testing.T, root, file string, noCalls bool) ([]movesEmission, []movesViolation) {
- t.Helper()
- at := func(p string) string {
- if root == "." {
- return p
- }
- return filepath.Join(root, p)
- }
- fset := movesFset
- f, err := parser.ParseFile(fset, at(file), nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- idx := *loadMovesPkg(t, at(filepath.Dir(file)))
- idx.noCalls = noCalls
- pkg := &idx
- var out []movesEmission
- var vios []movesViolation
- seenVio := map[string]bool{}
- var fn string
- var ctx *sinkCtx
- cur := pkg
- emit := func(pos token.Pos, kind string, e ast.Expr, fragment bool) {
- for _, p := range patternsOf(e, cur, 0) {
- if informative(p) {
- out = append(out, movesEmission{file, fset.Position(pos).Line, fset.Position(e.End()).Line, fn, kind, p, fragment})
- }
- }
- if fragment {
- return
- }
- if why := ctx.check(e, 0); why != "" {
- v := movesViolation{file, fset.Position(pos).Line, fn, kind, why}
- if !seenVio[v.String()] {
- seenVio[v.String()] = true
- vios = append(vios, v)
- }
- }
- }
- isCheck := func(x ast.Expr) bool {
- switch x := x.(type) {
- case *ast.Ident:
- return x.Name == "Check"
- case *ast.SelectorExpr:
- return x.Sel.Name == "Check"
- }
- return false
- }
- tables := map[string]bool{}
- for _, n := range movesCopyTables[file] {
- tables[n] = true
- }
- literals := map[string]bool{}
- for _, n := range movesLiteralFuncs[file] {
- literals[n] = true
- }
- for _, d := range f.Decls {
- fd, ok := d.(*ast.FuncDecl)
- if !ok || fd.Body == nil {
- continue
- }
- fn = fd.Name.Name
- fp := *pkg
- fp.locals = localAssigns(fd)
- cur = &fp
- ctx = newSinkCtx(file, fd, cur)
- inTable := tables[fn]
- strs := stringResults(fd)
- returnsSentence := movesReturnedSentences[file][fn] && len(strs) > 0
- var stack []ast.Node
- litDepth := 0
- ast.Inspect(fd.Body, func(n ast.Node) bool {
- if n == nil {
- if _, ok := stack[len(stack)-1].(*ast.FuncLit); ok {
- litDepth--
- }
- stack = stack[:len(stack)-1]
- return true
- }
- stack = append(stack, n)
- switch n := n.(type) {
- case *ast.FuncLit:
- litDepth++
- case *ast.BasicLit:
- // A literal with an em dash is main's wording, which phase1Conflict
- // matches and rewrites; it is never spoken. A literal here is a
- // piece of a sentence unless it is compared whole or returned.
- if literals[fn] && n.Kind == token.STRING && !strings.Contains(n.Value, `\u2014`) && !strings.ContainsRune(n.Value, '\u2014') {
- emit(n.Pos(), fn, n, true)
- }
- case *ast.CallExpr:
- name := ""
- switch fn := n.Fun.(type) {
- case *ast.Ident:
- name = fn.Name
- case *ast.SelectorExpr:
- name = fn.Sel.Name // a method: only local sinks, below
- }
- i, sink := movesLocalSinks[file][name]
- if id, ok := n.Fun.(*ast.Ident); ok && id.Name == "jsonError" {
- i, sink = 2, true
- }
- if sink && i < len(n.Args) {
- emit(n.Pos(), name, n.Args[i], false)
- }
- if j, fsink := movesFuncSinks[file][name]; fsink && j < len(n.Args) {
- if why := ctx.checkFuncArg(n.Args[j]); why != "" {
- v := movesViolation{file, fset.Position(n.Pos()).Line, fn, name, why}
- if !seenVio[v.String()] {
- seenVio[v.String()] = true
- vios = append(vios, v)
- }
- }
- }
- case *ast.CompositeLit:
- if n.Type != nil && isCheck(n.Type) {
- for _, el := range n.Elts {
- if kv, ok := el.(*ast.KeyValueExpr); ok {
- if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Reason" {
- emit(kv.Pos(), "Check.Reason", kv.Value, false)
- }
- }
- }
- }
- if inTable {
- for _, el := range n.Elts {
- if kv, ok := el.(*ast.KeyValueExpr); ok {
- switch kv.Value.(type) {
- case *ast.FuncLit, *ast.CompositeLit:
- // closures are read through their returns; nested tables by descent
- default:
- emit(kv.Pos(), fn, kv.Value, false)
- }
- }
- }
- }
- case *ast.AssignStmt:
- if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
- if s, ok := n.Lhs[0].(*ast.SelectorExpr); ok && s.Sel.Name == "Reason" && isCheck(s.X) {
- emit(n.Pos(), "Check.Reason", n.Rhs[0], false)
- }
- }
- case *ast.ReturnStmt:
- ctx.curReturn = n
- switch {
- case inTable:
- for _, r := range n.Results {
- if _, nested := r.(*ast.CompositeLit); !nested {
- emit(r.Pos(), fn, r, false)
- }
- }
- case literals[fn]:
- // phase1Conflict's returns are sentences too.
- for _, r := range n.Results {
- emit(r.Pos(), fn, r, false)
- }
- case returnsSentence && litDepth == 0:
- for i, r := range n.Results {
- if strs[i] {
- emit(r.Pos(), "returned sentence", r, false)
- }
- }
- }
- }
- return true
- })
- }
- return out, vios
-}
-
// ---- sinks must take sentences the gate can read ----
// movesFuncSinks are calls that take a sentence-producing function as an
@@ -1199,476 +695,6 @@ var movesMappingHelpers = map[string]map[string]bool{
"internal/console/apiinstancemove.go": {"phase1Conflict": true},
}
-// sinkCtx classifies what a sink argument is, inside one function.
-type sinkCtx struct {
- file string
- fd *ast.FuncDecl
- pkg *movesPkgIndex
- fwd map[string]bool // string parameters whose values are sinks at the call sites
- fwdFn map[string]bool // function parameters that are sentence helpers at the call sites
- curReturn *ast.ReturnStmt // the return being checked, for a mapping helper
- visiting map[string]bool // helpers and parameters being read, so a cycle ends
-}
-
-func paramNames(ft *ast.FuncType) []string {
- var out []string
- if ft.Params == nil {
- return out
- }
- for _, f := range ft.Params.List {
- if len(f.Names) == 0 {
- out = append(out, "_")
- }
- for _, n := range f.Names {
- out = append(out, n.Name)
- }
- }
- return out
-}
-
-func newSinkCtx(file string, fd *ast.FuncDecl, pkg *movesPkgIndex) *sinkCtx {
- c := &sinkCtx{file: file, fd: fd, pkg: pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}, visiting: map[string]bool{}}
- if fd == nil {
- return c
- }
- if i, ok := movesLocalSinks[file][fd.Name.Name]; ok {
- if ps := paramNames(fd.Type); i < len(ps) {
- c.fwd[ps[i]] = true
- }
- }
- if i, ok := movesFuncSinks[file][fd.Name.Name]; ok {
- if ps := paramNames(fd.Type); i < len(ps) {
- c.fwdFn[ps[i]] = true
- }
- }
- // A closure that is a local sink (fail := func(why string)) forwards its parameter.
- ast.Inspect(fd.Body, func(n ast.Node) bool {
- as, ok := n.(*ast.AssignStmt)
- if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
- return true
- }
- id, ok := as.Lhs[0].(*ast.Ident)
- lit, isLit := as.Rhs[0].(*ast.FuncLit)
- if !ok || !isLit {
- return true
- }
- if i, ok := movesLocalSinks[file][id.Name]; ok {
- if ps := paramNames(lit.Type); i < len(ps) {
- c.fwd[ps[i]] = true
- }
- }
- return true
- })
- return c
-}
-
-// sub is a context for another function of the package, sharing the cycle guard.
-func (c *sinkCtx) sub(d *ast.FuncDecl) *sinkCtx {
- hp := *c.pkg
- hp.locals = localAssigns(d)
- n := newSinkCtx(c.file, d, &hp)
- n.visiting = c.visiting
- return n
-}
-
-func (c *sinkCtx) pkgString(name string) (ast.Expr, bool) {
- if _, shadowed := c.pkg.locals[name]; shadowed {
- return nil, false
- }
- x, ok := c.pkg.vars[name]
- return x, ok
-}
-
-func isFormatCall(e *ast.CallExpr) bool {
- sel, ok := e.Fun.(*ast.SelectorExpr)
- if !ok || len(e.Args) == 0 {
- return false
- }
- id, ok := sel.X.(*ast.Ident)
- return ok && ((id.Name == "fmt" && (sel.Sel.Name == "Sprintf" || sel.Sel.Name == "Errorf")) ||
- (id.Name == "errors" && sel.Sel.Name == "New"))
-}
-
-func (c *sinkCtx) formatIsLiteral(e *ast.CallExpr) bool {
- switch a := e.Args[0].(type) {
- case *ast.BasicLit:
- return a.Kind == token.STRING
- case *ast.Ident:
- _, ok := c.pkgString(a.Name)
- return ok
- }
- return false
-}
-
-// literalish reports whether e is, on its face, a piece of fixed text: a
-// string literal, a package const, a Sprintf or errors.New with a literal
-// format, or a concatenation with such a part.
-func (c *sinkCtx) literalish(e ast.Expr) bool {
- switch e := e.(type) {
- case *ast.ParenExpr:
- return c.literalish(e.X)
- case *ast.BasicLit:
- return e.Kind == token.STRING
- case *ast.Ident:
- _, ok := c.pkgString(e.Name)
- return ok
- case *ast.CallExpr:
- return isFormatCall(e) && c.formatIsLiteral(e)
- case *ast.BinaryExpr:
- return e.Op == token.ADD && (c.literalish(e.X) || c.literalish(e.Y))
- }
- return false
-}
-
-func addOperands(e ast.Expr, out *[]ast.Expr) {
- if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.ADD {
- addOperands(b.X, out)
- addOperands(b.Y, out)
- return
- }
- if p, ok := e.(*ast.ParenExpr); ok {
- addOperands(p.X, out)
- return
- }
- *out = append(*out, e)
-}
-
-func render(e ast.Expr) string {
- var b strings.Builder
- ast.Inspect(e, func(n ast.Node) bool {
- switch n := n.(type) {
- case *ast.BinaryExpr:
- b.WriteString(render(n.X) + " " + n.Op.String() + " " + render(n.Y))
- return false
- case *ast.Ident:
- b.WriteString(n.Name)
- case *ast.SelectorExpr:
- b.WriteString(render(n.X) + "." + n.Sel.Name)
- return false
- case *ast.CallExpr:
- b.WriteString(render(n.Fun) + "(...)")
- return false
- }
- return true
- })
- return b.String()
-}
-
-// check returns why e is not a sentence the gate can read, or "".
-//
-// Taken: a string literal; a package const; a concatenation with a literal
-// part or a Sprintf with a literal format (the other operands are the
-// placeholders the record names); a call to a package function or method
-// whose result is a string and whose every return is itself taken; a local
-// variable whose every assignment is taken; a parameter that is a sink at its
-// call sites, or whose every call site passes a taken argument; a Check's own
-// Reason (every Check is made at a sink); a mapping helper's guarded echo.
-// Everything else is refused: err.Error(), a method's result the gate cannot
-// read, a field, an index, an expression it cannot read.
-func (c *sinkCtx) check(e ast.Expr, depth int) string {
- if depth > 6 {
- return ""
- }
- switch e := e.(type) {
- case *ast.ParenExpr:
- return c.check(e.X, depth)
- case *ast.BasicLit:
- if e.Kind == token.STRING {
- return ""
- }
- return "not a string literal"
- case *ast.Ident:
- return c.identProblem(e, depth)
- case *ast.SelectorExpr:
- if e.Sel.Name == "Reason" {
- return ""
- }
- return fmt.Sprintf("%s is a field the gate cannot read", render(e))
- case *ast.BinaryExpr:
- if e.Op != token.ADD {
- return "an expression that is not a sentence"
- }
- var ops []ast.Expr
- addOperands(e, &ops)
- lit := false
- for _, o := range ops {
- if c.literalish(o) {
- lit = true
- if why := c.check(o, depth+1); why != "" {
- return why
- }
- }
- }
- if !lit {
- return fmt.Sprintf("%s is a concatenation with no literal part", render(e))
- }
- return ""
- case *ast.CallExpr:
- if isFormatCall(e) {
- if !c.formatIsLiteral(e) {
- return fmt.Sprintf("%s has a format that is not a literal", render(e))
- }
- return ""
- }
- if id, ok := e.Fun.(*ast.Ident); ok && c.fwdFn[id.Name] {
- return ""
- }
- return c.callProblem(e, 0, depth)
- }
- return fmt.Sprintf("%s is not a sentence the gate can read", render(e))
-}
-
-func (c *sinkCtx) identProblem(id *ast.Ident, depth int) string {
- if c.fwd[id.Name] {
- return ""
- }
- if c.guardedEcho(id) {
- return ""
- }
- if as, ok := c.pkg.locals[id.Name]; ok {
- for _, a := range as {
- switch {
- case a.zero:
- case a.rhs != nil:
- if why := c.check(a.rhs, depth+1); why != "" {
- return fmt.Sprintf("%s is assigned something that is not a sentence: %s", id.Name, why)
- }
- case a.call != nil:
- if why := c.callProblem(a.call, a.pos, depth+1); why != "" {
- return fmt.Sprintf("%s is assigned something that is not a sentence: %s", id.Name, why)
- }
- default:
- return fmt.Sprintf("%s is assigned a value the gate cannot read", id.Name)
- }
- }
- return ""
- }
- if x, ok := c.pkgString(id.Name); ok {
- return (&sinkCtx{pkg: c.pkg, fwd: map[string]bool{}, fwdFn: map[string]bool{}, visiting: c.visiting}).check(x, depth+1)
- }
- if c.fd != nil {
- for i, n := range paramNames(c.fd.Type) {
- if n == id.Name {
- return c.paramProblem(i, id.Name, depth)
- }
- }
- }
- return fmt.Sprintf("%s is a variable, not a literal, a const or a sentence helper", id.Name)
-}
-
-// paramProblem checks a parameter used as a sentence: every call site in the
-// package must pass an argument that is a sentence.
-func (c *sinkCtx) paramProblem(idx int, name string, depth int) string {
- key := fmt.Sprintf("param %p %d", c.fd, idx)
- if c.visiting[key] {
- return ""
- }
- c.visiting[key] = true
- defer delete(c.visiting, key)
- sites := 0
- for _, d := range c.pkg.decls {
- if d.Body == nil {
- continue
- }
- var caller *sinkCtx
- var why string
- ast.Inspect(d.Body, func(n ast.Node) bool {
- call, ok := n.(*ast.CallExpr)
- if !ok || why != "" {
- return why == ""
- }
- named := ""
- switch f := call.Fun.(type) {
- case *ast.Ident:
- named = f.Name
- case *ast.SelectorExpr:
- named = f.Sel.Name
- }
- if named != c.fd.Name.Name || idx >= len(call.Args) {
- return true
- }
- sites++
- if caller == nil {
- caller = c.sub(d)
- }
- if w := caller.check(call.Args[idx], depth+1); w != "" {
- why = fmt.Sprintf("parameter %s is passed a value that is not a sentence at %s: %s", name, movesFset.Position(call.Pos()), w)
- }
- return true
- })
- if why != "" {
- return why
- }
- }
- if sites == 0 {
- return fmt.Sprintf("parameter %s has no call site in the package, so its value is unknown", name)
- }
- return ""
-}
-
-// callProblem checks result pos of a call: the callee must be a package
-// function or method with a string there, and every return of it must be a
-// sentence.
-func (c *sinkCtx) callProblem(e *ast.CallExpr, pos, depth int) string {
- if sel, ok := e.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" {
- return fmt.Sprintf("%s forwards an error's text", render(e))
- }
- decls := calleeDecls(e, c.pkg)
- if len(decls) == 0 {
- return fmt.Sprintf("%s forwards a call's result the gate cannot read", render(e))
- }
- for _, d := range decls {
- if why := c.helperProblem(d, pos, depth); why != "" {
- return why
- }
- }
- return ""
-}
-
-// helperProblem checks every return of d at result position pos.
-func (c *sinkCtx) helperProblem(d *ast.FuncDecl, pos, depth int) string {
- if d.Body == nil || !stringResults(d)[pos] {
- return fmt.Sprintf("%s(...) does not return a sentence at result %d", d.Name.Name, pos)
- }
- key := fmt.Sprintf("helper %p %d", d, pos)
- if c.visiting[key] {
- return ""
- }
- c.visiting[key] = true
- defer delete(c.visiting, key)
- hc := c.sub(d)
- nres := resultCount(d)
- var named []string
- for _, f := range d.Type.Results.List {
- for _, n := range f.Names {
- named = append(named, n.Name)
- }
- }
- var why string
- ast.Inspect(d.Body, func(n ast.Node) bool {
- switch n := n.(type) {
- case *ast.FuncLit:
- return false
- case *ast.ReturnStmt:
- if why != "" {
- return false
- }
- hc.curReturn = n
- var w string
- switch {
- case len(n.Results) == nres:
- w = hc.check(n.Results[pos], depth+1)
- case len(n.Results) == 1 && nres > 1:
- if call, ok := n.Results[0].(*ast.CallExpr); ok {
- w = hc.callProblem(call, pos, depth+1)
- }
- case len(n.Results) == 0 && pos < len(named):
- w = hc.check(&ast.Ident{Name: named[pos]}, depth+1)
- }
- hc.curReturn = nil
- if w != "" {
- why = fmt.Sprintf("helper %s, at %s: %s", d.Name.Name, movesFset.Position(n.Pos()), w)
- }
- }
- return why == ""
- })
- return why
-}
-
-// guardedEcho reports whether id is the whole of the return being checked,
-// inside a case of a mapping helper whose guard tests id against literals.
-func (c *sinkCtx) guardedEcho(id *ast.Ident) bool {
- if c.curReturn == nil || c.fd == nil || !movesMappingHelpers[c.file][c.fd.Name.Name] {
- return false
- }
- direct := false
- for _, r := range c.curReturn.Results {
- if r == ast.Expr(id) {
- direct = true
- }
- }
- if !direct {
- return false
- }
- var cc *ast.CaseClause
- var stack []ast.Node
- ast.Inspect(c.fd.Body, func(n ast.Node) bool {
- if n == nil {
- stack = stack[:len(stack)-1]
- return true
- }
- stack = append(stack, n)
- if n == ast.Node(c.curReturn) {
- for i := len(stack) - 1; i >= 0; i-- {
- if k, ok := stack[i].(*ast.CaseClause); ok {
- cc = k
- break
- }
- }
- }
- return cc == nil
- })
- if cc == nil || len(cc.List) == 0 {
- return false
- }
- for _, g := range cc.List {
- if !guardsLiteral(g, id.Name, c) {
- return false
- }
- }
- return true
-}
-
-// guardsLiteral reports whether g tests name against literals only: name ==
-// literal, or HasPrefix, HasSuffix or Contains of name and a literal, joined
-// by &&.
-func guardsLiteral(g ast.Expr, name string, c *sinkCtx) bool {
- switch g := g.(type) {
- case *ast.ParenExpr:
- return guardsLiteral(g.X, name, c)
- case *ast.BinaryExpr:
- switch g.Op {
- case token.LAND:
- return guardsLiteral(g.X, name, c) && guardsLiteral(g.Y, name, c)
- case token.EQL:
- l, lok := g.X.(*ast.Ident)
- r, rok := g.Y.(*ast.Ident)
- switch {
- case lok && l.Name == name:
- return c.literalish(g.Y) && !(rok && r.Name == name)
- case rok && r.Name == name:
- return c.literalish(g.X)
- }
- }
- case *ast.CallExpr:
- sel, ok := g.Fun.(*ast.SelectorExpr)
- if !ok || len(g.Args) != 2 {
- return false
- }
- x, isID := sel.X.(*ast.Ident)
- a0, a0ok := g.Args[0].(*ast.Ident)
- if !isID || x.Name != "strings" || !a0ok || a0.Name != name {
- return false
- }
- switch sel.Sel.Name {
- case "HasPrefix", "HasSuffix", "Contains":
- return c.literalish(g.Args[1])
- }
- }
- return false
-}
-
-// checkFuncArg checks the function a call hands to a sentence-function
-// parameter: a package function that is a sentence helper.
-func (c *sinkCtx) checkFuncArg(e ast.Expr) string {
- if id, ok := e.(*ast.Ident); ok {
- if d, isFn := c.pkg.funcs[id.Name]; isFn {
- return c.helperProblem(d, 0, 0)
- }
- }
- return fmt.Sprintf("%s is passed where a sentence helper is expected", render(e))
-}
-
// diagnosticPrefixes are the ids the readiness code puts before a reason
// ("T11: ", "S2: ") and the record leaves on its row or off it.
var diagnosticPrefixes = []string{"T11: ", "S1: ", "S2: "}
@@ -1790,8 +816,6 @@ func f(w int, c Check) {
jsonError(w, 400, "bad region "+c.Region+" given")
_ = Check{Reason: noKey}
_ = moves.Check{Reason: why(3)}
- _ = Check{Reason: "-"}
- _ = Check{Reason: c.Other}
c.Check.Reason = fmt.Sprintf("took %d of %d", 1, 2)
}
`
@@ -2009,28 +1033,6 @@ func TestMovesGateBitesOnTheRealRecord(t *testing.T) {
}
}
-// stringResults is the set of a function's result positions that are strings.
-func stringResults(d *ast.FuncDecl) map[int]bool {
- out := map[int]bool{}
- if d.Type.Results == nil {
- return out
- }
- i := 0
- for _, f := range d.Type.Results.List {
- n := len(f.Names)
- if n == 0 {
- n = 1
- }
- if id, ok := f.Type.(*ast.Ident); ok && id.Name == "string" {
- for k := 0; k < n; k++ {
- out[i+k] = true
- }
- }
- i += n
- }
- return out
-}
-
// movesViolationExempt reports whether a violation is main's own (legacy) or
// is allow-listed with a reason.
func movesViolationExempt(v movesViolation) bool {
@@ -2063,10 +1065,28 @@ func TestMovesSinksAreSentences(t *testing.T) {
// ---- negative fixtures for the sink rule, the matcher and the placeholder check ----
-func sinkFixture(t *testing.T, dir string) []movesViolation {
+// fixtureScan scans a sink fixture in its own universe, and returns the
+// universe too, for the function-value check.
+func fixtureScan(t *testing.T, dir string) ([]movesEmission, []movesViolation, *movesUniverse) {
+ t.Helper()
+ file := filepath.Join("testdata", "moves-copy", "sinks", dir, "emit.go")
+ u := newUniverse(t, ".", []string{file})
+ ems, vios := scanMovesFileIn(t, u, ".", file, false)
+ return ems, vios, u
+}
+
+// fixtureRows is the fixture's record.md, or a record with one unrelated row.
+func fixtureRows(t *testing.T, dir string) []copyRow {
t.Helper()
- _, v := scanMovesFileFull(t, ".", filepath.Join("testdata", "moves-copy", "sinks", dir, "emit.go"), false)
- return v
+ path := filepath.Join("testdata", "moves-copy", "sinks", dir, "record.md")
+ if _, err := os.Stat(path); err == nil {
+ rows, err := parseMovesCopyRecord(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rows
+ }
+ return []copyRow{{ID: "MV-N1", File: "x.go", Text: "an unrelated sentence", Status: "pending"}}
}
func wantViolations(t *testing.T, name string, got []movesViolation, wants ...string) {
@@ -2082,31 +1102,108 @@ func wantViolations(t *testing.T, name string, got []movesViolation, wants ...st
}
}
+// Each counterexample fails the sink gate, and, where the value is one the
+// record should have seen, fails the record gate too: an underived value matches
+// no row, and a substituted or named-result sentence is read as it is spoken.
func TestMovesSinkFixtures(t *testing.T) {
- wantViolations(t, "a forwarded error", sinkFixture(t, "forwarded-error"),
- "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text",
- "emit.go:18 (Check.Reason in report): helper why, at ",
- )
- wantViolations(t, "a local with one assignment that is an error's text", sinkFixture(t, "local-variable"),
- "emit.go:10 (jsonError in mixed): msg is assigned something that is not a sentence: err.Error(...) forwards an error's text",
- "emit.go:15 (jsonError in fromMultiValue): text is assigned something that is not a sentence: helper reasonOf, at ",
- "emit.go:24 (jsonError in rangeVar): n is assigned a value the gate cannot read",
- )
- wantViolations(t, "a helper with one bad return", sinkFixture(t, "helper-bad-return"),
- "emit.go:7 (jsonError in refuse): helper say, at testdata/moves-copy/sinks/helper-bad-return/emit.go:14:2: err.Error(...) forwards an error's text",
- )
- wantViolations(t, "a parameter one call site gives an error", sinkFixture(t, "param"),
- "emit.go:6 (jsonError in refuse): helper wrap, at ",
- "emit.go:7 (jsonError in refuse): helper wrap, at ",
- )
- wantViolations(t, "values the gate cannot read", sinkFixture(t, "unresolved"),
- "emit.go:6 (jsonError in methodResult): s.render(...) forwards a call's result the gate cannot read",
- "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read",
- "emit.go:14 (Check.Reason in bare): a + b is a concatenation with no literal part",
- "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal",
- "emit.go:22 (jsonError in field): r.Message is a field the gate cannot read",
- )
- wantViolations(t, "sentences the gate can read", sinkFixture(t, "ok"))
+ cases := []struct {
+ dir string
+ vios []string // substrings, in order
+ unrecorded int // emissions no row of the fixture's record covers
+ }{
+ {"forwarded-error", []string{
+ "emit.go:7 (jsonError in refuse): err.Error(...) forwards an error's text",
+ "emit.go:18 (Check.Reason in report): helper why, at ",
+ }, 2},
+ {"sprintf-v", []string{
+ "emit.go:9 (jsonError in viaSprintf): a value with no text of its own",
+ "emit.go:13 (jsonError in viaConcat): a value with no text of its own",
+ "emit.go:17 (jsonError in viaVerbs): a value with no text of its own",
+ }, 3},
+ {"field-reason", []string{
+ "emit.go:12 (jsonError in positional): a write to field Reason at ",
+ "emit.go:12 (jsonError in positional): a write to field Reason at ",
+ "emit.go:12 (jsonError in positional): a write to field Reason at ",
+ "emit.go:17 (jsonError in keyed): a write to field Reason at ",
+ "emit.go:17 (jsonError in keyed): a write to field Reason at ",
+ "emit.go:17 (jsonError in keyed): a write to field Reason at ",
+ "emit.go:22 (jsonError in assigned): a write to field Reason at ",
+ }, 4},
+ {"local-variable", []string{
+ "emit.go:10 (jsonError in mixed): err.Error(...) forwards an error's text",
+ "emit.go:15 (jsonError in fromMultiValue): helper reasonOf, at ",
+ "emit.go:24 (jsonError in rangeVar): n is assigned a value the gate cannot read",
+ }, 4},
+ {"helper-bad-return", []string{
+ "emit.go:7 (jsonError in refuse): helper say, at testdata/moves-copy/sinks/helper-bad-return/emit.go:14:2: err.Error(...) forwards an error's text",
+ }, 2},
+ {"param", []string{
+ "emit.go:7 (jsonError in refuse): a value with no text of its own",
+ }, 2},
+ {"substitution", nil, 1},
+ {"named-result", nil, 1},
+ {"shadow", []string{
+ "emit.go:12 (jsonError in refuse): say at ",
+ }, 1},
+ {"interface", []string{
+ "emit.go:17 (jsonError in viaInterface): m.Message(...) at ",
+ "emit.go:21 (jsonError in viaUnknown): x.Message(...)",
+ }, 2},
+ {"funcvalue", []string{
+ "emit.go:8 (jsonError in send): parameter msg is not bound to an argument",
+ "emit.go:23 (jsonError in report): parameter msg is not bound to an argument",
+ }, 2},
+ {"limit", []string{
+ "emit.go:19 (jsonError in chainLiteral): analysis limit reached at ",
+ "emit.go:35 (jsonError in chainError): analysis limit reached at ",
+ }, 2},
+ {"unresolved", []string{
+ "emit.go:6 (jsonError in methodResult): s.render(...): no method render on server in this package",
+ "emit.go:10 (jsonError in indexed): names is not a sentence the gate can read",
+ "emit.go:14 (Check.Reason in bare): a value with no text of its own",
+ "emit.go:18 (jsonError in dynamicFormat): fmt.Sprintf(...) has a format that is not a literal",
+ "emit.go:18 (jsonError in dynamicFormat): parameter format is not bound to an argument",
+ "emit.go:22 (jsonError in field): r.Message: no struct in the scanned files declares a field Message",
+ }, 5},
+ {"ok", nil, 0},
+ }
+ for _, c := range cases {
+ ems, vios, _ := fixtureScan(t, c.dir)
+ wantViolations(t, c.dir, vios, c.vios...)
+ if un := findUnrecorded(ems, fixtureRows(t, c.dir)); len(un) != c.unrecorded {
+ t.Errorf("%s: %d unrecorded %v, want %d", c.dir, len(un), un, c.unrecorded)
+ }
+ }
+ // The sentences that are read as spoken.
+ for dir, want := range map[string]string{"substitution": "an unrecorded sentence", "named-result": "an unrecorded sentence"} {
+ ems, _, _ := fixtureScan(t, dir)
+ if len(ems) != 1 || ems[0].pattern != want {
+ t.Errorf("%s: emissions = %v, want exactly %q", dir, ems, want)
+ }
+ }
+}
+
+// A sentence-flow function referenced other than as a direct call lets a call
+// through the value escape the argument checks.
+func TestMovesFunctionValueFixture(t *testing.T) {
+ _, _, u := fixtureScan(t, "funcvalue")
+ var got []string
+ for _, r := range funcValueRefs(t, u) {
+ got = append(got, r.msg)
+ }
+ want := []string{"emit.go:16:7: send is used as a value", "emit.go:27:7: s.report is used as a value"}
+ if len(got) != len(want) {
+ t.Fatalf("refs = %q, want %d", got, len(want))
+ }
+ for i, w := range want {
+ if !strings.Contains(got[i], w) {
+ t.Errorf("ref %d = %q, want one containing %q", i, got[i], w)
+ }
+ }
+ _, _, ok := fixtureScan(t, "ok")
+ if refs := funcValueRefs(t, ok); len(refs) != 0 {
+ t.Errorf("the ok fixture: refs = %v", refs)
+ }
}
// A mapping helper may echo a refusal's text only in a case whose guard tests
@@ -2221,3 +1318,81 @@ func TestMovesApprovedPlaceholderTextIsOneExpression(t *testing.T) {
t.Errorf("problems = %q", got)
}
}
+
+// literalPatterns reads the texts of an expression written in place: literals,
+// concatenations, Sprintf and errors.New formats, and consts of the same file.
+// What it cannot read is a wildcard.
+func literalPatterns(e ast.Expr, vars map[string]ast.Expr, depth int) []string {
+ if depth > 6 {
+ return []string{wild}
+ }
+ switch e := e.(type) {
+ case *ast.BasicLit:
+ if v, err := strconv.Unquote(e.Value); err == nil && e.Kind == token.STRING {
+ return []string{v}
+ }
+ case *ast.ParenExpr:
+ return literalPatterns(e.X, vars, depth)
+ case *ast.Ident:
+ if x, ok := vars[e.Name]; ok {
+ return literalPatterns(x, vars, depth+1)
+ }
+ case *ast.BinaryExpr:
+ if e.Op == token.ADD {
+ var out []string
+ for _, l := range literalPatterns(e.X, vars, depth) {
+ for _, r := range literalPatterns(e.Y, vars, depth) {
+ out = append(out, l+r)
+ }
+ }
+ return out
+ }
+ case *ast.CallExpr:
+ if isFormatCall(e) {
+ var out []string
+ for _, f := range literalPatterns(e.Args[0], vars, depth+1) {
+ out = append(out, formatPattern(f))
+ }
+ return out
+ }
+ }
+ return []string{wild}
+}
+
+// A function that takes a sentence and is referenced other than as a direct
+// call (`f := send`, a method value, an argument) lets a call through the
+// value escape the argument checks. The real tree has none, bar the allow-list.
+func TestMovesFunctionValuesAreNotSinks(t *testing.T) {
+ u := universeFor(t, ".", movesScanFiles[0])
+ for _, f := range movesScanFiles {
+ scanMovesFileIn(t, u, ".", f, false)
+ }
+ refs := funcValueRefs(t, u)
+ reportAll(t, checkFuncValueAllow(movesFuncValueAllow, refs))
+ for _, r := range refs {
+ if _, ok := movesFuncValueAllow[r.file][r.name]; !ok {
+ t.Error(r.msg)
+ }
+ }
+}
+
+// An allow-list entry needs a real reason and must still exempt a reference.
+func checkFuncValueAllow(allow map[string]map[string]string, refs []funcRef) []string {
+ used := map[string]bool{}
+ for _, r := range refs {
+ used[r.file+" "+r.name] = true
+ }
+ var out []string
+ for file, names := range allow {
+ for name, why := range names {
+ if len(strings.Fields(why)) < 8 {
+ out = append(out, fmt.Sprintf("%s %s: the allow-list entry needs a real reason", file, name))
+ }
+ if !used[file+" "+name] {
+ out = append(out, fmt.Sprintf("%s %s: the allow-list entry exempts nothing; drop it", file, name))
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/testdata/moves-copy/sinks/field-reason/emit.go b/testdata/moves-copy/sinks/field-reason/emit.go
new file mode 100644
index 000000000..1e127527f
--- /dev/null
+++ b/testdata/moves-copy/sinks/field-reason/emit.go
@@ -0,0 +1,23 @@
+package fixture
+
+// Not compiled. A field is a sentence only if every write to a field of that
+// name is. The anonymous struct writes an error's text, positionally and by key.
+
+type Check struct{ Reason string }
+
+func good() Check { return Check{Reason: "this box has no secrets key"} }
+
+func positional(w int, err error) {
+ box := struct{ Reason string }{err.Error()}
+ jsonError(w, 400, box.Reason)
+}
+
+func keyed(w int, err error) {
+ box := struct{ Reason string }{Reason: err.Error()}
+ jsonError(w, 400, box.Reason)
+}
+
+func assigned(w int, err error, c Check) {
+ c.Reason = err.Error()
+ jsonError(w, 400, c.Reason)
+}
diff --git a/testdata/moves-copy/sinks/funcvalue/emit.go b/testdata/moves-copy/sinks/funcvalue/emit.go
new file mode 100644
index 000000000..6a7291859
--- /dev/null
+++ b/testdata/moves-copy/sinks/funcvalue/emit.go
@@ -0,0 +1,29 @@
+package fixture
+
+// Not compiled. send's parameter reaches a sink. A direct call passes a
+// sentence, but a call through a function value passes an error's text and
+// escapes the argument checks.
+
+func send(w int, msg string) {
+ jsonError(w, 400, msg)
+}
+
+func direct(w int) {
+ send(w, "invalid region")
+}
+
+func indirect(w int, err error) {
+ f := send
+ f(w, err.Error())
+}
+
+type server struct{}
+
+func (s server) report(w int, msg string) {
+ jsonError(w, 400, msg)
+}
+
+func methodValue(w int, s server, err error) {
+ g := s.report
+ g(w, err.Error())
+}
diff --git a/testdata/moves-copy/sinks/interface/emit.go b/testdata/moves-copy/sinks/interface/emit.go
new file mode 100644
index 000000000..543963e31
--- /dev/null
+++ b/testdata/moves-copy/sinks/interface/emit.go
@@ -0,0 +1,22 @@
+package fixture
+
+// Not compiled. The interface's implementation may live in another package and
+// say anything. A method selected only by its name is no better.
+
+type messenger interface {
+ Message() string
+}
+
+type local struct{}
+
+func (local) Message() string {
+ return "a recorded sentence"
+}
+
+func viaInterface(w int, m messenger) {
+ jsonError(w, 400, m.Message())
+}
+
+func viaUnknown(w int, x external) {
+ jsonError(w, 400, x.Message())
+}
diff --git a/testdata/moves-copy/sinks/limit/emit.go b/testdata/moves-copy/sinks/limit/emit.go
new file mode 100644
index 000000000..dc117b1a5
--- /dev/null
+++ b/testdata/moves-copy/sinks/limit/emit.go
@@ -0,0 +1,36 @@
+package fixture
+
+// Not compiled. A chain of local aliases longer than the gate reads. Running out
+// of depth is a violation, not an approval.
+
+func chainLiteral(w int) {
+ a1 := "this box has no secrets key"
+ a2 := a1
+ a3 := a2
+ a4 := a3
+ a5 := a4
+ a6 := a5
+ a7 := a6
+ a8 := a7
+ a9 := a8
+ a10 := a9
+ a11 := a10
+ a12 := a11
+ jsonError(w, 400, a12)
+}
+
+func chainError(w int, err error) {
+ b1 := err.Error()
+ b2 := b1
+ b3 := b2
+ b4 := b3
+ b5 := b4
+ b6 := b5
+ b7 := b6
+ b8 := b7
+ b9 := b8
+ b10 := b9
+ b11 := b10
+ b12 := b11
+ jsonError(w, 400, b12)
+}
diff --git a/testdata/moves-copy/sinks/named-result/emit.go b/testdata/moves-copy/sinks/named-result/emit.go
new file mode 100644
index 000000000..dbd571078
--- /dev/null
+++ b/testdata/moves-copy/sinks/named-result/emit.go
@@ -0,0 +1,12 @@
+package fixture
+
+// Not compiled. A bare return speaks the named result.
+
+func refuse(w int) {
+ jsonError(w, 400, say())
+}
+
+func say() (msg string) {
+ msg = "an unrecorded sentence"
+ return
+}
diff --git a/testdata/moves-copy/sinks/ok/emit.go b/testdata/moves-copy/sinks/ok/emit.go
index 928422261..c1bef0dc2 100644
--- a/testdata/moves-copy/sinks/ok/emit.go
+++ b/testdata/moves-copy/sinks/ok/emit.go
@@ -31,10 +31,6 @@ func tooMany(n int) string {
return "one box is too few"
}
-func checks(c Check) Check {
- return Check{Reason: c.Reason}
-}
-
// A local whose every assignment is a sentence.
func localLiteral(w int, bad bool) {
msg := "that region is not armed"
@@ -69,3 +65,14 @@ func wrapOK(m string) string {
func callsAreLiterals() string {
return wrapOK("second")
}
+
+// A field whose every write, in the struct that is read, is a sentence.
+type Check struct{ Reason string }
+
+func makeCheck() Check {
+ return Check{Reason: "this box has no secrets key"}
+}
+
+func viaField(w int, c Check) {
+ jsonError(w, 400, c.Reason)
+}
diff --git a/testdata/moves-copy/sinks/ok/record.md b/testdata/moves-copy/sinks/ok/record.md
new file mode 100644
index 000000000..07ec468f7
--- /dev/null
+++ b/testdata/moves-copy/sinks/ok/record.md
@@ -0,0 +1,15 @@
+# The sentences of the ok fixture, as a record would carry them
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-N1 | emit.go | invalid region | pending |
+| MV-N2 | emit.go | this box has no secrets key | pending |
+| MV-N3 | emit.go | region <to> is not armed | pending |
+| MV-N4 | emit.go | <n> boxes publish <region> | pending |
+| MV-N5 | emit.go | <n> boxes is too many | pending |
+| MV-N6 | emit.go | one box is too few | pending |
+| MV-N7 | emit.go | that region is not armed | pending |
+| MV-N8 | emit.go | that region is not hosting | pending |
+| MV-N9 | emit.go | the app is not enabled | pending |
+| MV-N10 | emit.go | <n> apps are not enabled | pending |
+| MV-N11 | emit.go | first | pending |
diff --git a/testdata/moves-copy/sinks/shadow/emit.go b/testdata/moves-copy/sinks/shadow/emit.go
new file mode 100644
index 000000000..882e189d8
--- /dev/null
+++ b/testdata/moves-copy/sinks/shadow/emit.go
@@ -0,0 +1,13 @@
+package fixture
+
+// Not compiled. The closure shadows the package function of the same name, so
+// the recorded text of the package function is not what is spoken.
+
+func say() string {
+ return "a recorded sentence"
+}
+
+func refuse(w int, err error) {
+ say := func() string { return err.Error() }
+ jsonError(w, 400, say())
+}
diff --git a/testdata/moves-copy/sinks/sprintf-v/emit.go b/testdata/moves-copy/sinks/sprintf-v/emit.go
new file mode 100644
index 000000000..f03cd54d9
--- /dev/null
+++ b/testdata/moves-copy/sinks/sprintf-v/emit.go
@@ -0,0 +1,18 @@
+package fixture
+
+import "fmt"
+
+// Not compiled. Both forward an error's text with no sentence of their own:
+// the pattern derives to a bare placeholder, which no row can vouch for.
+
+func viaSprintf(w int, err error) {
+ jsonError(w, 400, fmt.Sprintf("%v", err))
+}
+
+func viaConcat(w int, err error) {
+ jsonError(w, 400, ""+err.Error())
+}
+
+func viaVerbs(w int, err error) {
+ jsonError(w, 400, fmt.Sprintf("%s%s", "", err))
+}
diff --git a/testdata/moves-copy/sinks/substitution/emit.go b/testdata/moves-copy/sinks/substitution/emit.go
new file mode 100644
index 000000000..67c7cea37
--- /dev/null
+++ b/testdata/moves-copy/sinks/substitution/emit.go
@@ -0,0 +1,12 @@
+package fixture
+
+// Not compiled. The parameter is bound to the argument, so the sentence is
+// read as it is spoken: "an unrecorded sentence".
+
+func refuse(w int) {
+ jsonError(w, 400, wrap("an unrecorded sentence"))
+}
+
+func wrap(m string) string {
+ return m
+}