// approved_moves_copy_scan_test.go package main import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "slices" "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. // // A placeholder (an operand of a concatenation, an argument of a Sprintf) may // be data, such as a host name: the row it is matched against must carry a // at that spot, and the record is where a human vouches for it. // A placeholder must not carry an error's text that the gate can see. These // are violations there, even when the format matches a row (errorText): // // - a call to Error(); // - a value whose declared type is error or implements it; // - a value the gate cannot type that is named err or ends in Err or err; // - a local, or a helper parameter, that holds any of these; // - the argument of a %w verb; // - a call to a package helper with a string result, any of whose returns // carries an error's text, or that the gate cannot read (a return of a // shape it does not know, a receiver it cannot resolve, a call to // itself); // - a field, any of whose writes in the scanned files carries an error's // text or takes one result of a multi-value expression; // - any of these hitting the analysis depth limit. // // Data the gate cannot derive (a region read from a request, a list joined by // strings.Join, a host name in a field) may stand for a placeholder. That // includes a value made from a helper result the gate cannot read (a local // assigned it, a field written from it, an argument bound to it) when the // result carries no error's text the gate can see. The real tree's region // and host placeholders are read that way. The strict reading, where any // such value is a violation, refused 13 real Phase 1 sinks whose // placeholders are all data. // // What it is (ruling T15-scope): a lint against accidental drift into // operator copy, not a boundary against code written to evade it. The // whole-branch review and code review cover deliberate constructions. These // are its known limits: // // - Types are syntactic and same-package. It does not follow generics, // embedded structs, type aliases, or methods promoted through embedding. // A value of another package's type is an error only by its type's name // (one ending in Error) or, when its type is unknown, by its own name. // An error held in a variable of an unrelated name and an unknown type // (x := pkg.Do(); Sprintf("%v", x)) is read as data. So is a helper // result of any type other than string or error (a Stringer formatted by // %v). // - A helper reads its parameters by their call sites only where it is // called as a placeholder. A field written from the writer's own // parameter (target{Name: n}) is read by n's type, not by what the // writer's callers pass, so mk(err.Error()) through such a field is not // seen. // - Receivers are not lexically scoped. A method call whose receiver // identifier is declared more than once in the function is refused; a // receiver declared once is resolved by its one declaration. // - Struct fields written by JSON decoding, reflection, through a pointer // to the field (p := &x.Reason; *p = ...), with +=, or in files outside // movesScanFiles are not seen. A field read reflects the writes it can // see. A write that takes one result of a multi-value expression is a // non-sentence write. // - A Reason read as a placeholder whose writes fail only softly (a // variable the gate cannot read, not an error's text or a limit) is a // wildcard, as any other placeholder is. // - Function values: a direct reference to a sentence-flow function (a // package function, a method, or a closure configured in movesLocalSinks) // that is not a call is refused. A closure that is not configured is not // tracked as a value; its own parameters are unbound at its sinks, which // refuses them there. // - Anything that leaves the scanned files (a map or channel carrying text // from elsewhere, a global set at init) is read as far as the files say // and no further. // 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, "StuckReason": 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 multi bool // one result of a multi-value expression: not provable } 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 fieldTexts map[string]string // fieldText's answer per field and struct used map[string]bool // package functions evaluated as sentence helpers paramSinks map[string]bool // functions whose parameter reaches a sink files []string steps int // diagSkipped counts, per movesDiagnosticWrites entry, the writes the // universe left out of the field's writes. diagSkipped map[string]int } // callsError reports whether e calls an Error method: the write carries an // error's text. Only such writes can be listed in movesDiagnosticWrites. func callsError(e ast.Expr) bool { found := false ast.Inspect(e, func(n ast.Node) bool { if c, ok := n.(*ast.CallExpr); ok { if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" { found = true } } return !found }) return found } 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{}, fieldTexts: map[string]string{}, used: map[string]bool{}, paramSinks: map[string]bool{}, files: files, diagSkipped: map[string]int{}} 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 { if fd != nil { if key := fd.Name.Name + "|" + ctype + "." + k.Name; movesDiagnosticWrites[f][key].Why != "" && callsError(kv.Value) { u.diagSkipped[f+"|"+key]++ continue } } u.fieldWrites[k.Name] = append(u.fieldWrites[k.Name], &fieldWrite{file: f, fd: fd, expr: kv.Value, ctype: ctype}) } } else if i < len(order) { u.fieldWrites[order[i]] = append(u.fieldWrites[order[i]], &fieldWrite{file: f, fd: fd, expr: el, ctype: ctype}) } } case *ast.AssignStmt: for i, l := range n.Lhs { s, ok := l.(*ast.SelectorExpr) if !ok { continue } if len(n.Rhs) == len(n.Lhs) { u.fieldWrites[s.Sel.Name] = append(u.fieldWrites[s.Sel.Name], &fieldWrite{file: f, fd: fd, expr: n.Rhs[i], recv: s.X}) } else { // box.Reason, _ = detail(err) or box.Reason, ok = m[k]: one // result of a multi-value expression, which the gate cannot // prove is a sentence. The expression is the field written. u.fieldWrites[s.Sel.Name] = append(u.fieldWrites[s.Sel.Name], &fieldWrite{file: f, fd: fd, expr: s, recv: s.X, multi: true}) } } } 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 errParams map[string]string // a helper's parameters bound to an error's text, and why 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{}, errParams: map[string]string{}, 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 . Only a // hard problem (a limit, a cycle, an error's text) 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 // , 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. A write's hard problem (a limit, an error's text, a // multi-value write) is never covered by the wildcard. if movesSentenceFields[sel.Sel.Name] { a, p := c.fieldRead(sel) if len(p) == 0 { return a, nil } return []string{wild}, hardOnly(p) } return []string{wild}, nil } alts, probs := c.eval(e, depth) if len(probs) == 0 { return alts, nil } return []string{wild}, hardOnly(probs) } // concatOperand reads one side of a +: a placeholder, unless it is an error's // text. A side that is itself a concatenation is checked by its own eval. func (c *evalCtx) concatOperand(e ast.Expr, depth int) ([]string, []problem) { if !isConcat(e) { if why := c.errorText(e, 0); why != "" { return []string{wild}, []problem{errorTextProblem(e, why)} } } return c.operand(e, depth) } func isConcat(e ast.Expr) bool { switch e := e.(type) { case *ast.ParenExpr: return isConcat(e.X) case *ast.BinaryExpr: return e.Op == token.ADD } return false } func errorTextProblem(e ast.Expr, why string) problem { what := "an error's text" m, rest := whyClass(why) if m != "" { what = "text the gate cannot derive" } return problem{fmt.Sprintf("%s puts %s in a sentence (%s)", render(e), what, rest), true} } // errName is the name an error usually goes by: err, or one ending in Err or // err. It is used only when the gate cannot type the value. func errName(n string) bool { return strings.HasSuffix(n, "err") || strings.HasSuffix(n, "Err") } // isErrorType reports whether a declared type is error or implements it: the // error interface, a package type with an Error method, a package interface // that embeds error or declares Error, or another package's type whose name // ends in Error. func (c *evalCtx) isErrorType(t ast.Expr) bool { switch t := unstar(t).(type) { case *ast.Ident: if t.Name == "error" { return true } for _, d := range c.pkg.methods["Error"] { if recvBase(d) == t.Name { return true } } if it, ok := c.pkg.types[t.Name].(*ast.InterfaceType); ok && it.Methods != nil { for _, m := range it.Methods.List { if id, ok := m.Type.(*ast.Ident); ok && id.Name == "error" && len(m.Names) == 0 { return true } for _, n := range m.Names { if n.Name == "Error" { return true } } } } case *ast.SelectorExpr: return strings.HasSuffix(t.Sel.Name, "Error") } return false } // The classes of a reason errorText gives, carried as a mark at its front. // An unmarked reason is an error's text, and it counts wherever the value // goes. An unprovable one (an analysis limit, a multi-value field write) counts // wherever the value goes too: the gate does not know what the text is. An // underivable one (a helper result the gate cannot read: a return of a shape // it does not know, a receiver it cannot resolve, a call to itself) counts only // for the value that is that result. A value merely made from it (a local // assigned it, an argument bound to it, a field written from it) is data, as // a placeholder may be: the real tree's region and host names are read // through helpers the gate cannot fully derive. const ( underivable = "\x03" unprovable = "\x04" ) func whyClass(why string) (mark, rest string) { if strings.HasPrefix(why, underivable) || strings.HasPrefix(why, unprovable) { return why[:1], why[1:] } return "", why } // rewrap wraps why in format (why is its last verb), keeping its class. func rewrap(why, format string, a ...any) string { m, rest := whyClass(why) return m + fmt.Sprintf(format, append(a, rest)...) } // dataWhy is why as it passes through data: an underivable result drops out. func dataWhy(why string) string { if m, _ := whyClass(why); m == underivable { return "" } return why } // errorText says why e is an error's text, or text the gate cannot derive // (see the classes above), or "" when the gate has no reason to think either. // It reads the expression as written, the locals it names, the bodies of the // package helpers it calls, and the writes of the fields it reads. func (c *evalCtx) errorText(e ast.Expr, depth int) string { if depth > maxEvalDepth { return fmt.Sprintf("%sanalysis limit reached at %s: the gate ran out of depth reading what text it carries", unprovable, c.posOf(e)) } switch e := e.(type) { case *ast.ParenExpr: return c.errorText(e.X, depth+1) case *ast.BinaryExpr: if e.Op != token.ADD { return "" } if why := c.errorText(e.X, depth+1); why != "" { return why } return c.errorText(e.Y, depth+1) case *ast.CallExpr: if sel, ok := e.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" && len(e.Args) == 0 { return "it calls Error" } if t := c.typeExprOf(e, 0); t != nil && c.isErrorType(t) { return "its result is an error" } // A call out of the package (fmt.Sprint, strings.TrimSpace) carries the // text of its arguments. A package helper is read through its body. decls, p := c.resolveCallees(e) if p != nil && p.hard { return underivable + p.msg // a receiver the gate cannot resolve } if p != nil { for _, a := range e.Args { if why := dataWhy(c.errorText(a, depth+1)); why != "" { return rewrap(why, "it is made from %s, and %s", render(a)) } } return "" } return c.helperText(e, decls, 0, depth) case *ast.Ident: if why, ok := c.errParams[e.Name]; ok { return why } t := c.typeExprOf(e, 0) if t != nil && c.isErrorType(t) { return "its type is error" } key := fmt.Sprintf("errtext %p %s", c.fd, e.Name) if !c.visiting[key] { c.visiting[key] = true defer delete(c.visiting, key) for _, a := range c.locals[e.Name] { switch { case a.rhs != nil: if why := dataWhy(c.errorText(a.rhs, depth+1)); why != "" { if _, rest := whyClass(why); strings.HasPrefix(rest, "it is assigned ") { return why // an alias chain: its innermost step says it } return rewrap(why, "it is assigned %s at %s, and %s", render(a.rhs), c.posOf(a.rhs)) } case a.call != nil: if decls, p := c.resolveCallees(a.call); p == nil && len(decls) > 0 && decls[0].Type != nil { if rt := resultTypeAt(decls[0], a.pos); rt != nil && c.isErrorType(rt) { return fmt.Sprintf("it is assigned an error result at %s", c.posOf(a.call)) } if why := dataWhy(c.helperText(a.call, decls, a.pos, depth)); why != "" { return rewrap(why, "it is assigned %s at %s, and %s", render(a.call), c.posOf(a.call)) } } } } } if t == nil && errName(e.Name) { return "the gate cannot type it, and it is named as an error" } case *ast.SelectorExpr: t := c.typeExprOf(e, 0) if t != nil && c.isErrorType(t) { return "its type is error" } if t == nil && errName(e.Sel.Name) { return "the gate cannot type it, and it is named as an error" } return c.fieldText(e) } return "" } func isStringType(t ast.Expr) bool { id, ok := t.(*ast.Ident) return ok && id.Name == "string" } // bindErrParams marks hc's parameters that call binds to an error's text (or // to text the gate cannot prove). They are refused where the helper speaks // them, not where it only compares them. func (c *evalCtx) bindErrParams(hc *evalCtx, d *ast.FuncDecl, call *ast.CallExpr, depth int) { for i, p := range paramNames(d.Type) { if i < len(call.Args) { if why := dataWhy(c.errorText(call.Args[i], depth)); why != "" { hc.errParams[p] = rewrap(why, "it is bound to %s at %s, and %s", render(call.Args[i]), c.posOf(call.Args[i])) } } } } // helperText reads a call to a package helper whose result at pos is a string, // used as a placeholder, through its body: each return is read with errorText // in the helper's own context, its parameters marked where the call binds // them to an error's text. A return that carries an error's text, or one the // gate cannot read, makes the call a violation. A return of data (a parameter, // a region read from a request, a list joined by strings.Join) may stand for // a , as any placeholder may. A helper with another result type // (an int for %d) is not text. func (c *evalCtx) helperText(e *ast.CallExpr, decls []*ast.FuncDecl, pos, depth int) string { if c.noCalls || len(decls) == 0 { return "" } for _, d := range decls { if d.Type == nil || d.Body == nil || !isStringType(resultTypeAt(d, pos)) { return "" } } if depth > maxEvalDepth { return fmt.Sprintf("%sanalysis limit reached at %s: helpers nested too deeply", unprovable, c.posOf(e)) } for _, d := range decls { key := fmt.Sprintf("errhelper %p %d", d, pos) if c.visiting[key] { return fmt.Sprintf("%shelper %s calls itself at %s", underivable, d.Name.Name, c.posOf(e)) } dfile := strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/") hc := c.u.newCtx(dfile, d, c.noCalls) hc.visiting = c.visiting c.bindErrParams(hc, d, e, depth+1) c.visiting[key] = true why := hc.returnsText(d, pos, depth+1) delete(c.visiting, key) switch m, _ := whyClass(why); { case why == "": case m == "": return fmt.Sprintf("helper %s returns an error's text: %s", d.Name.Name, why) default: return rewrap(why, "helper %s returns text the gate cannot derive: %s", d.Name.Name) } } return "" } // returnsText reads each return of d at pos with errorText (c is d's own // context), as callDecl reads them for a sentence. func (c *evalCtx) returnsText(d *ast.FuncDecl, pos, depth int) string { nres := resultCount(d) named := namedResults(d) why := "" ast.Inspect(d.Body, func(n ast.Node) bool { if why != "" { return false } switch n := n.(type) { case *ast.FuncLit: return false case *ast.ReturnStmt: var r string switch { case len(n.Results) == nres && pos < len(n.Results): r = c.errorText(n.Results[pos], depth) case len(n.Results) == 1 && nres > 1: cl, ok := n.Results[0].(*ast.CallExpr) if !ok { r = fmt.Sprintf("%sreturn at %s has a shape the gate cannot read", underivable, c.posOf(n)) break } decls, p := c.resolveCallees(cl) switch { case p != nil && p.hard: r = underivable + p.msg case p != nil: r = fmt.Sprintf("%sreturn at %s forwards %s, which the gate cannot read", underivable, c.posOf(n), render(cl)) default: r = c.helperText(cl, decls, pos, depth) } case len(n.Results) == 0 && pos < len(named): r = c.errorText(&ast.Ident{Name: named[pos], NamePos: n.Pos()}, depth) default: r = fmt.Sprintf("%sreturn at %s has a shape the gate cannot read", underivable, c.posOf(n)) } if r != "" { why = rewrap(r, "at %s: %s", c.posOf(n)) } } return true }) return why } // fieldText reads a field used as a placeholder through its writes, as // fieldRead does for a Reason: a write that carries an error's text, or takes // one result of a multi-value expression, makes the read one too. A write of // data (a parameter, a variable, a helper result the gate cannot fully read) // may stand for a . Writes to a same-named field of another known // struct are not this field's. func (c *evalCtx) fieldText(e *ast.SelectorExpr) string { if c.noCalls { return "" } f := e.Sel.Name rt := lastName(baseTypeName(c.typeExprOf(e.X, 0))) key := f + "|" + rt if why, ok := c.u.fieldTexts[key]; ok { return why // a field read while its own writes are read is clean here } c.u.fieldTexts[key] = "" why := "" for _, w := range c.u.fieldWrites[f] { wc := c.u.newCtx(w.file, w.fd, c.noCalls) wt := lastName(w.ctype) if w.recv != nil { wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0))) } if rt != "" && wt != "" && wt != rt { continue } if w.multi { why = fmt.Sprintf("%sa write to field %s at %s takes one result of a multi-value expression, which the gate cannot prove is a sentence", unprovable, f, wc.posOf(w.expr)) break } // From depth 0: the answer is cached per field, so it must not depend // on how deep the first reader was. A field cycle reads as clean (the // entry above), and the step limit still bounds the work. if r := dataWhy(wc.errorText(w.expr, 0)); r != "" { why = rewrap(r, "a write to field %s at %s: %s", f, wc.posOf(w.expr)) break } } c.u.fieldTexts[key] = why return why } // wrapArgs returns the argument indexes (into the call's Args, the format at // 0) of a format's %w verbs. ok is false when the format's arguments cannot be // counted (an explicit argument index). func wrapArgs(format string) (idx []int, ok bool) { if strings.Contains(format, "%[") { return nil, !strings.Contains(format, "w") } arg := 1 for _, v := range formatVerb.FindAllString(format, -1) { if v == "%%" { continue } arg += strings.Count(v, "*") if strings.HasSuffix(v, "w") { idx = append(idx, arg) } arg++ } return idx, true } // 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.concatOperand(e.X, depth+1) r, rp := c.concatOperand(e.Y, depth+1) out, ok := cross(l, r) if !ok { return nil, append(append(lp, rp...), limit(c, e, "too many alternative sentences")...) } // The text is kept beside the problems, so a refused sentence is still // named by what it says. return dedupe(out), append(lp, rp...) 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 why, ok := c.errParams[id.Name]; ok { // Spoken, not merely compared: the helper returns or builds text from a // parameter bound to an error's text. what := "an error's text" m, rest := whyClass(why) if m != "" { what = "text the gate cannot derive" } return nil, []problem{{fmt.Sprintf("%s is %s (%s)", id.Name, what, rest), true}} } 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. // fieldWriteAlts is the sentences written to the field e reads, in the struct // type it reads (every write, when the type is unknown). A write that is // itself forwarded keeps its mark: it is checked where it is made. func (c *evalCtx) fieldWriteAlts(e *ast.SelectorExpr) []string { f := e.Sel.Name rt := lastName(baseTypeName(c.typeExprOf(e.X, 0))) var alts []string for _, w := range c.u.fieldWrites[f] { wc := c.u.newCtx(w.file, w.fd, c.noCalls) wt := lastName(w.ctype) if w.recv != nil { wt = lastName(baseTypeName(wc.typeExprOf(w.recv, 0))) } if rt != "" && wt != "" && wt != rt { continue } x, _ := wc.eval(w.expr, 0) alts = append(alts, x...) } return dedupe(alts) } 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++ if w.multi { res.probs = append(res.probs, problem{fmt.Sprintf("a write to field %s at %s takes one result of a multi-value expression, which the gate cannot prove is a sentence", f, wc.posOf(w.expr)), true}) continue } 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 var argProbs []problem flagged := map[int]bool{} for i, a := range e.Args[1:] { if why := c.errorText(a, 0); why != "" { flagged[i+1] = true argProbs = append(argProbs, errorTextProblem(a, why)) } } 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)) idx, ok := wrapArgs(f) if !ok { argProbs = append(argProbs, problem{fmt.Sprintf("%s has a %%w verb whose argument the gate cannot count", render(e)), true}) } for _, i := range idx { switch { case i >= len(e.Args): argProbs = append(argProbs, problem{fmt.Sprintf("%s has a %%w verb with no argument", render(e)), true}) case !flagged[i]: flagged[i] = true argProbs = append(argProbs, problem{fmt.Sprintf("%s is the argument of a %%w verb, an error", render(e.Args[i])), true}) } } } return dedupe(out), argProbs } 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 { // Hard: an error's text is never a placeholder either. return nil, &problem{fmt.Sprintf("%s forwards an error's text", render(e)), true} } if d, ok, prob := c.importedCallee(fn); ok { if prob != nil { return nil, prob } return []*ast.FuncDecl{d}, nil } if x, ok := fn.X.(*ast.Ident); ok && c.fd != nil && declCount(c.fd, x.Name) > 1 { // The gate types a receiver by its first declaration. A receiver // declared again (if m := supplied; ...) may be another type. return nil, &problem{fmt.Sprintf("%s at %s: the receiver %s is declared more than once in %s, so the gate cannot tell which one this is", render(e), c.posOf(e), x.Name, c.fd.Name.Name), true} } 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)) } // movesModule is the module path a cross-package call must stay inside. const movesModule = "github.com/carlosframework/platform/" // movesImportCache holds each scanned file's imports, name to path. var movesImportCache = map[string]map[string]string{} // fileImports reads file's imports: each one's name (its alias, or its // path's last element) to its path. func (c *evalCtx) fileImports() map[string]string { path := c.u.at(c.file) if m, ok := movesImportCache[path]; ok { return m } m := map[string]string{} movesImportCache[path] = m f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) if err != nil { c.u.t.Fatal(err) } for _, im := range f.Imports { p := strings.Trim(im.Path.Value, `"`) name := p[strings.LastIndex(p, "/")+1:] if im.Name != nil { name = im.Name.Name } m[name] = p } return m } // importedCallee resolves pkg.Func, a call into another package of this // module (Task 3 fix round 1: the shared templates of // internal/moves/operatorcopy.go). ok is false when fn is not such a call: // its X names something this function or package declares, or no import. // The callee's file must itself be scanned, so its sentences answer to the // record like any other; a callee anywhere else is refused, as a call out of // the package always was; so is one the record does not register as // returning sentences (movesReturnedSentences), so a call into the module's // other code stays out of the package. The callee is read in its own package's context // (callDecl's newCtx), with this call's arguments bound. func (c *evalCtx) importedCallee(fn *ast.SelectorExpr) (d *ast.FuncDecl, ok bool, prob *problem) { x, isIdent := fn.X.(*ast.Ident) if !isIdent { return nil, false, nil } if _, local := c.locals[x.Name]; local || (c.fd != nil && declCount(c.fd, x.Name) > 0) { return nil, false, nil } if _, v := c.pkg.vars[x.Name]; v { return nil, false, nil } path, imported := c.fileImports()[x.Name] if !imported || !strings.HasPrefix(path, movesModule) { return nil, false, nil } dir := strings.TrimPrefix(path, movesModule) registered := false for f, fns := range movesReturnedSentences { if filepath.Dir(f) == dir && fns[fn.Sel.Name] { registered = true } } if !registered { return nil, false, nil } if _, err := os.Stat(c.u.at(dir)); err != nil { return nil, false, nil } pkg := loadMovesPkg(c.u.t, c.u.at(dir)) d, found := pkg.funcs[fn.Sel.Name] if !found { return nil, false, nil } dfile := strings.TrimPrefix(movesFset.Position(d.Pos()).Filename, c.u.root+"/") if !slices.Contains(c.u.files, dfile) || !movesReturnedSentences[dfile][fn.Sel.Name] { return nil, false, nil } return d, true, nil } // declCount counts the declarations of name in fd: its receiver, parameters // and named results, and in its body every := , var, range variable and // closure parameter or result. A := that redeclares in the same scope counts // too; the gate does not scope. func declCount(fd *ast.FuncDecl, name string) int { n := 0 fields := func(fl *ast.FieldList) { if fl == nil { return } for _, f := range fl.List { for _, id := range f.Names { if id.Name == name { n++ } } } } fields(fd.Recv) fields(fd.Type.Params) fields(fd.Type.Results) if fd.Body == nil { return n } ident := func(e ast.Expr) { if id, ok := e.(*ast.Ident); ok && id.Name == name { n++ } } ast.Inspect(fd.Body, func(x ast.Node) bool { switch x := x.(type) { case *ast.AssignStmt: if x.Tok == token.DEFINE { for _, l := range x.Lhs { ident(l) } } case *ast.ValueSpec: for _, id := range x.Names { ident(id) } case *ast.RangeStmt: if x.Tok == token.DEFINE { ident(x.Key) ident(x.Value) } case *ast.FuncLit: fields(x.Type.Params) fields(x.Type.Results) } return true }) return n } // 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) { if onlyFormatArg(d, p) { // A parameter the helper only passes to a format call as an // argument is a placeholder there, read as a direct format // call reads its arguments: for an error's text (below), not // as a sentence. hc.bind[p] = []string{wild} } else { a, pr := c.operand(call.Args[i], depth+1) hc.bind[p] = a probs = append(probs, pr...) } // An argument that is an error's text may still only select among // sentences; it is refused where the helper puts it in one. if why := dataWhy(c.errorText(call.Args[i], 0)); why != "" { hc.errParams[p] = rewrap(why, "it is bound to %s at %s, and %s", render(call.Args[i]), c.posOf(call.Args[i])) } } 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 } // onlyFormatArg is true when every use of the parameter p in d's body is an // argument, not the format, of a format call (operatorcopy.go's templates). func onlyFormatArg(d *ast.FuncDecl, p string) bool { uses, asArg := 0, 0 ast.Inspect(d.Body, func(n ast.Node) bool { switch n := n.(type) { case *ast.Ident: if n.Name == p { uses++ } case *ast.CallExpr: if isFormatCall(n) { for _, a := range n.Args[1:] { if id, ok := a.(*ast.Ident); ok && id.Name == p { asArg++ } } } } return true }) return uses > 0 && uses == asArg && declCount(d, p) == 1 } // ---- 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 fromParam bool // the value is made from a string or error parameter of fn } 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) // A sentence field read whole at a sink speaks what was written to it: // each write's sentence needs its row here (fix round 2 of Plan 6 // Task 1a: a StuckReason written with a literal was otherwise passed // as checked where it is made, which only checked it was a sentence). if sel, ok := e.(*ast.SelectorExpr); ok && movesSentenceFields[sel.Sel.Name] && len(probs) == 0 { alts = ctx.fieldWriteAlts(sel) } fromParam := false 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 fromParam = 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, fromParam}) 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, fromParam}) } } 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 } // movesServerTextSpec names a file's server text: the type that carries text // the console wrote, its one field, the one function that makes it (Maker, // which must read the body of a response the HTTP client returned), and the // one sink that says it (Sink, which takes the type). Nothing else in the // package may make the type, write its field or read it. type movesServerTextSpec struct{ Type, Field, Maker, Sink string } type serverTextProblem struct{ fn, msg string } func (p serverTextProblem) String() string { return p.fn + ": " + p.msg } // checkServerText holds a server text type to its spec, across every non-test // Go file of file's package directory. func checkServerText(t *testing.T, file string, spec movesServerTextSpec) []serverTextProblem { t.Helper() dir := filepath.Dir(file) names, err := filepath.Glob(filepath.Join(dir, "*.go")) if err != nil { t.Fatal(err) } var out []serverTextProblem maker, sink := false, false var files []*ast.File consts := map[string]bool{} // the package's named constants for _, name := range names { if strings.HasSuffix(name, "_test.go") { continue } f, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) if err != nil { t.Fatal(err) } files = append(files, f) for _, d := range f.Decls { if g, ok := d.(*ast.GenDecl); ok && g.Tok == token.CONST { for _, sp := range g.Specs { for _, n := range sp.(*ast.ValueSpec).Names { consts[n.Name] = true } } } } } mentions := func(n ast.Node) bool { found := false ast.Inspect(n, func(x ast.Node) bool { if id, ok := x.(*ast.Ident); ok && id.Name == spec.Type { found = true } return !found }) return found } for _, f := range files { for _, d := range f.Decls { // Server text is never a package-level value: a var or const whose // type is or holds the type, or a composite literal of it outside // a function body (fix round 3, hole 1). if g, ok := d.(*ast.GenDecl); ok && (g.Tok == token.VAR || g.Tok == token.CONST) { for _, sp := range g.Specs { if vs := sp.(*ast.ValueSpec); mentions(vs) { for _, n := range vs.Names { out = append(out, serverTextProblem{"", fmt.Sprintf("declares %s, a package-level %s", n.Name, spec.Type)}) } } } continue } fd, ok := d.(*ast.FuncDecl) if !ok || fd.Body == nil { continue } fn := fd.Name.Name if fd.Recv != nil { fn = recvBase(fd) + "." + fn } writes := map[ast.Node]bool{} readsBody, callsDo := false, false localConsts := map[string]bool{} ast.Inspect(fd.Body, func(n ast.Node) bool { if g, ok := n.(*ast.GenDecl); ok && g.Tok == token.CONST { for _, sp := range g.Specs { for _, nm := range sp.(*ast.ValueSpec).Names { localConsts[nm.Name] = true } } } return true }) ast.Inspect(fd.Body, func(n ast.Node) bool { switch n := n.(type) { case *ast.AssignStmt: for _, l := range n.Lhs { if s, ok := l.(*ast.SelectorExpr); ok && s.Sel.Name == spec.Field { writes[s] = true if fn != spec.Maker { out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("writes %s.%s outside %s", spec.Type, spec.Field, spec.Maker)}) } } } case *ast.CompositeLit: id, ok := n.Type.(*ast.Ident) if !ok || id.Name != spec.Type { break } if fn != spec.Maker { out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("makes a %s outside %s", spec.Type, spec.Maker)}) break } // In the maker the text is a value read from the decoded // response: never a string literal or a named constant // (fix round 3, hole 2). for _, el := range n.Elts { v := el if kv, ok := el.(*ast.KeyValueExpr); ok { v = kv.Value } for p, ok := v.(*ast.ParenExpr); ok; p, ok = v.(*ast.ParenExpr) { v = p.X } what := "" switch v := v.(type) { case *ast.BasicLit: what = v.Value case *ast.Ident: if consts[v.Name] || localConsts[v.Name] { what = v.Name } } if what != "" { out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("makes a %s from %s, not a value read from the response", spec.Type, what)}) } } case *ast.SelectorExpr: switch { case n.Sel.Name == "Body": readsBody = true case n.Sel.Name == "Do": callsDo = true case n.Sel.Name == spec.Field && !writes[n] && fn != spec.Maker && fn != spec.Sink: out = append(out, serverTextProblem{fd.Name.Name, fmt.Sprintf("reads %s.%s outside %s", spec.Type, spec.Field, spec.Sink)}) } } return true }) if fn == spec.Maker { maker = true if !readsBody || !callsDo { out = append(out, serverTextProblem{fn, "makes server text but reads no body of a response the HTTP client returned"}) } } if fn == spec.Sink { for _, fld := range fd.Type.Params.List { if id, ok := fld.Type.(*ast.Ident); ok && id.Name == spec.Type { sink = true } } } } } if !maker && !strings.Contains(dir, "testdata") { out = append(out, serverTextProblem{spec.Maker, "the maker is not declared"}) } if !sink { out = append(out, serverTextProblem{spec.Sink, "the sink does not take " + spec.Type}) } return out } // 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) } // A closure configured as a local sink (verifyArchive's fail) is a // sentence-flow function of this file: an alias of it (send := fail) // escapes the checks at its call sites. closures := map[string]bool{} ast.Inspect(af, func(n ast.Node) bool { var names []*ast.Ident var values []ast.Expr switch n := n.(type) { case *ast.AssignStmt: for _, l := range n.Lhs { id, _ := l.(*ast.Ident) names = append(names, id) } values = n.Rhs case *ast.ValueSpec: names, values = n.Names, n.Values } if len(names) != len(values) { return true } for i, id := range names { if _, lit := values[i].(*ast.FuncLit); lit && id != nil { if _, ok := movesLocalSinks[f][id.Name]; ok { closures[id.Name] = true } } } return true }) 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.AssignStmt: // The name being defined or assigned is not a use of its value. for _, l := range n.Lhs { if id, ok := l.(*ast.Ident); ok { skip[id] = true } } case *ast.ValueSpec: for _, id := range n.Names { skip[id] = 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] || closures[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 }