// Package lint holds the checks this repository runs over its own source (novox/hq to-be 45 Phase 2). // // **Empty on error** (ADR 0227 rule 4, "nothing is dropped silently"): a reader that cannot read and // answers an empty collection is a reader whose caller cannot tell *nothing is there* from *I could not // tell* — issue 241's unreadable contributions file read as "nobody asks", and seven databases were // dropped. EmptyOnError finds every error branch that answers an empty collection with no error; the // test beside it fails the build on each one that does not say, where it does it, why the empty answer // is the truth (a `// empty-on-error: ` comment on the return or the line above). package lint import ( "fmt" "go/ast" "go/parser" "go/token" "io/fs" "path/filepath" "sort" "strings" ) // Allow is the comment that says, at the return, why an empty answer to an error is the truth. const Allow = "empty-on-error:" // Finding is one error branch answering an empty collection. type Finding struct { File string Line int Func string // Allowed is the reason given where it was allowed; empty for a finding that is a failure. Allowed string } func (f Finding) String() string { return fmt.Sprintf("%s:%d (%s)", f.File, f.Line, f.Func) } // EmptyOnError walks every Go file under root but tests, vendor/ and testdata/, and answers every // return inside an `if != nil` branch that answers an empty collection — nil, or a literal with no // elements, where the function answers a slice or a map — and no error: a nil error, or none declared. func EmptyOnError(root string) ([]Finding, error) { var out []Finding fset := token.NewFileSet() err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { switch d.Name() { case "vendor", "testdata", ".git", "node_modules": return filepath.SkipDir } return nil } if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return nil } file, err := parser.ParseFile(fset, path, nil, parser.ParseComments) if err != nil { return fmt.Errorf("%s cannot be read: %w", path, err) } rel, _ := filepath.Rel(root, path) out = append(out, inFile(fset, file, rel)...) return nil }) sort.Slice(out, func(i, j int) bool { if out[i].File != out[j].File { return out[i].File < out[j].File } return out[i].Line < out[j].Line }) return out, err } // inFile is every finding in one file. func inFile(fset *token.FileSet, file *ast.File, rel string) []Finding { // Every comment's text by the line it ends on, so an allowance is read on the return's line or the // line above it. comments := map[int]string{} for _, group := range file.Comments { for _, c := range group.List { comments[fset.Position(c.End()).Line] += c.Text } } var out []Finding var walk func(name string, results *ast.FieldList, body *ast.BlockStmt) walk = func(name string, results *ast.FieldList, body *ast.BlockStmt) { if body == nil { return } ast.Inspect(body, func(n ast.Node) bool { switch n := n.(type) { case *ast.FuncLit: // Its returns are its own, judged against its own results. walk(name+" (a function inside it)", n.Type.Results, n.Body) return false case *ast.IfStmt: if !errNotNil(n.Cond) { return true } for _, ret := range returnsIn(n.Body) { if !emptyOnError(results, ret) { continue } line := fset.Position(ret.Pos()).Line f := Finding{File: rel, Line: line, Func: name} for _, l := range []int{line, line - 1} { if text, ok := comments[l]; ok { if _, why, found := strings.Cut(text, Allow); found && strings.TrimSpace(why) != "" { f.Allowed = strings.TrimSpace(why) } } } out = append(out, f) } } return true }) } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok { continue } name := fn.Name.Name if fn.Recv != nil && len(fn.Recv.List) > 0 { name = "(" + exprString(fn.Recv.List[0].Type) + ")." + name } walk(name, fn.Type.Results, fn.Body) } return out } // errNotNil is a condition ` != nil` where x is an error by its name: err, or one ending in Err. func errNotNil(cond ast.Expr) bool { found := false ast.Inspect(cond, func(n ast.Node) bool { b, ok := n.(*ast.BinaryExpr) if !ok || b.Op != token.NEQ { return true } x, xok := b.X.(*ast.Ident) y, yok := b.Y.(*ast.Ident) if xok && yok && y.Name == "nil" && (x.Name == "err" || strings.HasSuffix(x.Name, "Err")) { found = true } return !found }) return found } // returnsIn is the returns of a branch, not those of a function literal inside it. func returnsIn(body *ast.BlockStmt) []*ast.ReturnStmt { var out []*ast.ReturnStmt ast.Inspect(body, func(n ast.Node) bool { switch n := n.(type) { case *ast.FuncLit: return false case *ast.ReturnStmt: out = append(out, n) } return true }) return out } // emptyOnError says a return answers an empty collection and no error, for a function's results. func emptyOnError(results *ast.FieldList, ret *ast.ReturnStmt) bool { if results == nil { return false } var types []ast.Expr for _, f := range results.List { n := len(f.Names) if n == 0 { n = 1 } for i := 0; i < n; i++ { types = append(types, f.Type) } } if len(ret.Results) != len(types) { return false // a bare return of named results, or a call answering them: not judged here } empty := false for i, t := range types { value := ret.Results[i] if isError(t) { if !isNil(value) { return false // an error is answered: said, not dropped } continue } if isCollection(t) && isEmpty(value) { empty = true } } return empty } func isError(t ast.Expr) bool { id, ok := t.(*ast.Ident) return ok && id.Name == "error" } func isCollection(t ast.Expr) bool { switch t := t.(type) { case *ast.ArrayType: return t.Len == nil case *ast.MapType: return true } return false } func isNil(e ast.Expr) bool { id, ok := e.(*ast.Ident) return ok && id.Name == "nil" } func isEmpty(e ast.Expr) bool { if isNil(e) { return true } lit, ok := e.(*ast.CompositeLit) return ok && len(lit.Elts) == 0 } func exprString(e ast.Expr) string { switch e := e.(type) { case *ast.StarExpr: return "*" + exprString(e.X) case *ast.Ident: return e.Name case *ast.IndexExpr: return exprString(e.X) } return "?" }