summaryrefslogtreecommitdiff
path: root/pkgtools/pkglint/files/intqa/qa.go
diff options
context:
space:
mode:
Diffstat (limited to 'pkgtools/pkglint/files/intqa/qa.go')
-rw-r--r--pkgtools/pkglint/files/intqa/qa.go540
1 files changed, 540 insertions, 0 deletions
diff --git a/pkgtools/pkglint/files/intqa/qa.go b/pkgtools/pkglint/files/intqa/qa.go
new file mode 100644
index 00000000000..532f710a388
--- /dev/null
+++ b/pkgtools/pkglint/files/intqa/qa.go
@@ -0,0 +1,540 @@
+// Package intqa provides quality assurance for the pkglint code.
+package intqa
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io"
+ "os"
+ "path"
+ "reflect"
+ "sort"
+ "strings"
+ "unicode"
+)
+
+type Error int
+
+const (
+ ENone Error = iota + 1
+ EAll
+
+ // A function or method does not have a corresponding test.
+ EMissingTest
+
+ // The name of a test function does not correspond to a program
+ // element to be tested.
+ EMissingTestee
+
+ // The tests are not in the same order as their corresponding
+ // testees in the main code.
+ EOrder
+
+ // The test method does not have a valid name.
+ EName
+
+ // The file of the test method does not correspond to the
+ // file of the testee.
+ EFile
+
+ // All methods of a type must be in the same file as the type definition.
+ EMethodsSameFile
+)
+
+// QAChecker ensures that all test names follow a common naming scheme:
+// Test_${Type}_${Method}__${description_using_underscores}
+// Each of the variable parts may be omitted.
+type QAChecker struct {
+ errorf func(format string, args ...interface{})
+
+ filters []filter
+ order int
+
+ testees []*testee
+ tests []*test
+
+ errors []string
+ out io.Writer
+}
+
+// NewQAChecker creates a new checker.
+// By default, all errors are enabled;
+// call Configure to disable them selectively.
+func NewQAChecker(errorf func(format string, args ...interface{})) *QAChecker {
+ ck := QAChecker{errorf: errorf, out: os.Stderr}
+
+ // For test fixtures from https://gopkg.in/check/v1.
+ ck.Configure("*_test.go", "*", "SetUpTest", -EMissingTest)
+ ck.Configure("*_test.go", "*", "TearDownTest", -EMissingTest)
+
+ // See https://github.com/rillig/gobco.
+ ck.Configure("gobco_*.go", "gobco*", "*", -EMissingTest)
+ ck.Configure("gobco_*.go", "", "gobco*", -EMissingTest)
+
+ return &ck
+}
+
+// Configure sets the errors that are activated for the given code,
+// specified by shell patterns like in path.Match.
+//
+// All rules are applied in order. Later rules overwrite earlier rules.
+//
+// Individual errors can be enabled by giving their constant and disabled
+// by negating them, such as -EMissingTestee. To reset everything, use
+// either EAll or ENone.
+func (ck *QAChecker) Configure(filenames, typeNames, funcNames string, errors ...Error) {
+ ck.filters = append(ck.filters, filter{filenames, typeNames, funcNames, errors})
+}
+
+func (ck *QAChecker) Check() {
+ ck.load(".")
+ ck.checkTestees()
+ ck.checkTests()
+ ck.checkOrder()
+ ck.print()
+}
+
+// load loads all type, function and method names from the current package.
+func (ck *QAChecker) load(dir string) {
+
+ fileSet := token.NewFileSet()
+ pkgs, err := parser.ParseDir(fileSet, dir, nil, 0)
+ if err != nil {
+ panic(err)
+ }
+
+ for _, pkgname := range sortedKeys(pkgs) {
+ files := pkgs[pkgname].Files
+
+ for _, filename := range sortedKeys(files) {
+ file := files[filename]
+ for _, decl := range file.Decls {
+ ck.loadDecl(decl, filename)
+ }
+ }
+ }
+
+ ck.relate()
+}
+
+// loadDecl adds a single type or function declaration to the known elements.
+func (ck *QAChecker) loadDecl(decl ast.Decl, filename string) {
+ switch decl := decl.(type) {
+
+ case *ast.GenDecl:
+ for _, spec := range decl.Specs {
+ switch spec := spec.(type) {
+ case *ast.TypeSpec:
+ typeName := spec.Name.Name
+ ck.addCode(code{filename, typeName, "", 0}, nil)
+ }
+ }
+
+ case *ast.FuncDecl:
+ code := ck.parseFuncDecl(filename, decl)
+ ck.addCode(code, decl)
+ }
+}
+
+func (*QAChecker) parseFuncDecl(filename string, decl *ast.FuncDecl) code {
+ typeName := ""
+ if decl.Recv != nil {
+ typeExpr := decl.Recv.List[0].Type.(ast.Expr)
+ if star, ok := typeExpr.(*ast.StarExpr); ok {
+ typeExpr = star.X
+ }
+ typeName = typeExpr.(*ast.Ident).Name
+ }
+
+ funcName := decl.Name.Name
+ return code{filename, typeName, funcName, 0}
+}
+
+func (ck *QAChecker) addCode(code code, decl *ast.FuncDecl) {
+ if code.isTestScope() && code.isFunc() && code.Func == "TestMain" {
+ // This is not a test for Main, but a wrapper function of the test.
+ // Therefore it is completely ignored.
+ // See https://golang.org/pkg/testing/#hdr-Main.
+ //
+ // Among others, this function is created by
+ // https://github.com/rillig/gobco when measuring the branch
+ // coverage of a package.
+ return
+ }
+
+ if !ck.isRelevant(code.file, code.Type, code.Func, EAll) {
+ return
+ }
+
+ if ck.isTest(code, decl) {
+ ck.addTest(code)
+ } else {
+ ck.addTestee(code)
+ }
+}
+
+func (*QAChecker) isTest(code code, decl *ast.FuncDecl) bool {
+ if !code.isTestScope() || !strings.HasPrefix(code.Func, "Test") {
+ return false
+ }
+ if decl.Type.Params.NumFields() != 1 {
+ return false
+ }
+
+ paramType := decl.Type.Params.List[0].Type
+ if star, ok := paramType.(*ast.StarExpr); ok {
+ paramType = star.X
+ }
+ if sel, ok := paramType.(*ast.SelectorExpr); ok {
+ paramType = sel.Sel
+ }
+
+ paramTypeName := paramType.(*ast.Ident).Name
+ return paramTypeName == "C" || paramTypeName == "T"
+}
+
+func (ck *QAChecker) addTestee(code code) {
+ code.order = ck.nextOrder()
+ ck.testees = append(ck.testees, &testee{code})
+}
+
+func (ck *QAChecker) addTest(code code) {
+ if !strings.HasPrefix(code.Func, "Test_") &&
+ code.Func != "Test" &&
+ ck.addError(
+ EName,
+ code,
+ "Test %q must start with %q.",
+ code.fullName(), "Test_") {
+
+ return
+ }
+
+ parts := strings.SplitN(code.Func, "__", 2)
+ testeeName := strings.TrimPrefix(strings.TrimPrefix(parts[0], "Test"), "_")
+ descr := ""
+ if len(parts) > 1 {
+ if parts[1] == "" &&
+ ck.addError(
+ EName,
+ code,
+ "Test %q must have a nonempty description.",
+ code.fullName()) {
+ return
+ }
+ descr = parts[1]
+ }
+
+ code.order = ck.nextOrder()
+ ck.tests = append(ck.tests, &test{code, testeeName, descr, nil})
+}
+
+func (ck *QAChecker) nextOrder() int {
+ id := ck.order
+ ck.order++
+ return id
+}
+
+// relate connects the tests to their testees.
+func (ck *QAChecker) relate() {
+ testeesByPrefix := make(map[string]*testee)
+ for _, testee := range ck.testees {
+ prefix := join(testee.Type, "_", testee.Func)
+ testeesByPrefix[prefix] = testee
+ }
+
+ for _, test := range ck.tests {
+ test.testee = testeesByPrefix[test.testeeName]
+ }
+}
+
+func (ck *QAChecker) checkTests() {
+ for _, test := range ck.tests {
+ ck.checkTestFile(test)
+ ck.checkTestTestee(test)
+ ck.checkTestDescr(test)
+ }
+}
+
+func (ck *QAChecker) checkTestFile(test *test) {
+ testee := test.testee
+ if testee == nil || testee.file == test.file {
+ return
+ }
+
+ correctTestFile := testee.testFile()
+ if correctTestFile == test.file {
+ return
+ }
+
+ ck.addError(
+ EFile,
+ test.code,
+ "Test %q for %q must be in %s instead of %s.",
+ test.fullName(), testee.fullName(), correctTestFile, test.file)
+}
+
+func (ck *QAChecker) checkTestTestee(test *test) {
+ testee := test.testee
+ if testee != nil || test.testeeName == "" {
+ return
+ }
+
+ testeeName := strings.Replace(test.testeeName, "_", ".", -1)
+ ck.addError(
+ EMissingTestee,
+ test.code,
+ "Missing testee %q for test %q.",
+ testeeName, test.fullName())
+}
+
+// checkTestDescr ensures that the type or function name of the testee
+// does not accidentally end up in the description of the test. This could
+// happen if there is a double underscore instead of a single underscore.
+func (ck *QAChecker) checkTestDescr(test *test) {
+ testee := test.testee
+ if testee == nil || testee.isMethod() || !isCamelCase(test.descr) {
+ return
+ }
+
+ ck.addError(
+ EName,
+ testee.code,
+ "%s: Test description %q must not use CamelCase in the first word.",
+ test.fullName(), test.descr)
+}
+
+func (ck *QAChecker) checkTestees() {
+ ck.checkTesteesTest()
+ ck.checkTesteesMethodsSameFile()
+}
+
+// checkTesteesTest ensures that each testee has a corresponding unit test.
+func (ck *QAChecker) checkTesteesTest() {
+ tested := make(map[*testee]bool)
+ for _, test := range ck.tests {
+ tested[test.testee] = true
+ }
+
+ for _, testee := range ck.testees {
+ if tested[testee] {
+ continue
+ }
+
+ testName := "Test_" + join(testee.Type, "_", testee.Func)
+ ck.addError(
+ EMissingTest,
+ testee.code,
+ "Missing unit test %q for %q.",
+ testName, testee.fullName())
+ }
+}
+
+// checkTesteesMethodsSameFile ensures that all methods of a type are
+// defined in the same file or in the corresponding test file.
+func (ck *QAChecker) checkTesteesMethodsSameFile() {
+ types := map[string]*testee{}
+ for _, testee := range ck.testees {
+ if testee.isType() {
+ types[testee.Type] = testee
+ }
+ }
+
+ for _, testee := range ck.testees {
+ if !testee.isMethod() {
+ continue
+ }
+ method := testee
+
+ typ := types[method.Type]
+ if typ == nil || method.file == typ.file {
+ continue
+ }
+
+ if method.isTestScope() == typ.isTestScope() {
+ ck.addError(
+ EMethodsSameFile,
+ testee.code,
+ "Method %s must be in %s, like its type.",
+ testee.fullName(), typ.file)
+ continue
+ }
+
+ correctFile := typ.testFile()
+ if method.file == correctFile {
+ continue
+ }
+
+ ck.addError(
+ EMethodsSameFile,
+ testee.code,
+ "Method %s must be in %s, corresponding to its type.",
+ testee.fullName(), correctFile)
+ }
+}
+
+// isRelevant checks whether the given error is enabled.
+func (ck *QAChecker) isRelevant(filename, typeName, funcName string, e Error) bool {
+ mask := ^uint64(0)
+ for _, filter := range ck.filters {
+ if matches(filename, filter.filenames) &&
+ matches(typeName, filter.typeNames) &&
+ matches(funcName, filter.funcNames) {
+ mask = ck.errorsMask(mask, filter.errors...)
+ }
+ }
+ return mask&ck.errorsMask(0, e) != 0
+}
+
+func (ck *QAChecker) errorsMask(mask uint64, errors ...Error) uint64 {
+ for _, err := range errors {
+ if err == ENone {
+ mask = 0
+ } else if err == EAll {
+ mask = ^uint64(0)
+ } else if err < 0 {
+ mask &= ^(uint64(1) << -uint(err))
+ } else {
+ mask |= uint64(1) << uint(err)
+ }
+ }
+ return mask
+}
+
+// checkOrder ensures that the tests appear in the same order as their
+// counterparts in the main code.
+func (ck *QAChecker) checkOrder() {
+ maxOrderByFile := make(map[string]*test)
+
+ for _, test := range ck.tests {
+ testee := test.testee
+ if testee == nil {
+ continue
+ }
+
+ maxOrder := maxOrderByFile[testee.file]
+ if maxOrder == nil || testee.order > maxOrder.testee.order {
+ maxOrderByFile[testee.file] = test
+ }
+
+ if maxOrder != nil && testee.order < maxOrder.testee.order {
+ insertBefore := maxOrder
+ for _, before := range ck.tests {
+ if before.file == test.file && before.testee != nil && before.testee.order > testee.order {
+ insertBefore = before
+ break
+ }
+ }
+
+ ck.addError(
+ EOrder,
+ test.code,
+ "Test %q must be ordered before %q.",
+ test.fullName(), insertBefore.fullName())
+ }
+ }
+}
+
+func (ck *QAChecker) addError(e Error, c code, format string, args ...interface{}) bool {
+ relevant := ck.isRelevant(c.file, c.Type, c.Func, e)
+ if relevant {
+ ck.errors = append(ck.errors, fmt.Sprintf(format, args...))
+ }
+ return relevant
+}
+
+func (ck *QAChecker) print() {
+ for _, msg := range ck.errors {
+ _, _ = fmt.Fprintln(ck.out, msg)
+ }
+
+ n := len(ck.errors)
+ if n > 1 {
+ ck.errorf("%d errors.", n)
+ } else if n == 1 {
+ ck.errorf("%d error.", n)
+ }
+}
+
+type filter struct {
+ filenames string
+ typeNames string
+ funcNames string
+ errors []Error
+}
+
+// testee is an element of the source code that can be tested.
+type testee struct {
+ code
+}
+
+type test struct {
+ code
+
+ testeeName string // The method name without the "Test_" prefix and description
+ descr string // The part after the "__" in the method name
+ testee *testee
+}
+
+// code is either a type, a function or a method.
+type code struct {
+ file string // the file containing the code
+ Type string // the type, e.g. MkLine
+ Func string // the function or method name, e.g. Warnf
+ order int // the relative order in the file
+}
+
+func (c *code) fullName() string { return join(c.Type, ".", c.Func) }
+func (c *code) isFunc() bool { return c.Type == "" }
+func (c *code) isType() bool { return c.Func == "" }
+func (c *code) isMethod() bool { return c.Type != "" && c.Func != "" }
+
+func (c *code) isTestScope() bool {
+ return strings.HasSuffix(c.file, "_test.go")
+}
+
+func (c *code) testFile() string {
+ if strings.HasSuffix(c.file, "_test.go") {
+ return c.file
+ }
+ return strings.TrimSuffix(c.file, ".go") + "_test.go"
+}
+
+func isCamelCase(str string) bool {
+ for i := 0; i+1 < len(str); i++ {
+ if str[i] == '_' {
+ return false
+ }
+ if unicode.IsLower(rune(str[i])) && unicode.IsUpper(rune(str[i+1])) {
+ return true
+ }
+ }
+ return false
+}
+
+func join(a, sep, b string) string {
+ if a == "" || b == "" {
+ sep = ""
+ }
+ return a + sep + b
+}
+
+func matches(subj string, pattern string) bool {
+ ok, err := path.Match(pattern, subj)
+ if err != nil {
+ panic(err)
+ }
+ return ok
+}
+
+// sortedKeys returns the sorted keys from an arbitrary map.
+func sortedKeys(m interface{}) []string {
+ var keys []string
+ for _, key := range reflect.ValueOf(m).MapKeys() {
+ keys = append(keys, key.Interface().(string))
+ }
+ sort.Strings(keys)
+ return keys
+}