/* Copyright 2017 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package main provides a tool that scans kubernetes e2e test source code // looking for conformance test declarations, which it emits on stdout. It // also looks for legacy, manually added "[Conformance]" tags and reports an // error if it finds any. // // This approach is not air tight, but it will serve our purpose as a // pre-submit check. package main import ( "flag" "fmt" "go/ast" "go/parser" "go/token" "io/ioutil" "os" "path/filepath" "regexp" "strconv" "strings" ) var ( baseURL = flag.String("url", "https://github.com/kubernetes/kubernetes/tree/master/", "location of the current source") confDoc = flag.Bool("conformance", false, "write a conformance document") totalConfTests, totalLegacyTests, missingComments int ) const regexDescribe = "Describe|KubeDescribe|SIGDescribe" const regexContext = "Context" type visitor struct { FileSet *token.FileSet lastDescribe describe cMap ast.CommentMap //list of all the conformance tests in the path tests []conformanceData } //describe contains text associated with ginkgo describe container type describe struct { text string lastContext context } //context contain the text associated with the Context clause type context struct { text string } type conformanceData struct { // A URL to the line of code in the kube src repo for the test URL string // Extracted from the "Testname:" comment before the test TestName string // Extracted from the "Description:" comment before the test Description string } func (v *visitor) convertToConformanceData(at *ast.BasicLit) { cd := conformanceData{} comment := v.comment(at) pos := v.FileSet.Position(at.Pos()) cd.URL = fmt.Sprintf("%s%s#L%d", *baseURL, pos.Filename, pos.Line) lines := strings.Split(comment, "\n") cd.Description = "" for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Testname:") { line = strings.TrimSpace(line[9:]) cd.TestName = line continue } if strings.HasPrefix(line, "Description:") { line = strings.TrimSpace(line[12:]) } cd.Description += line + "\n" } if cd.TestName == "" { testName := v.getDescription(at.Value) i := strings.Index(testName, "[Conformance]") if i > 0 { cd.TestName = strings.TrimSpace(testName[:i]) } else { cd.TestName = testName } } v.tests = append(v.tests, cd) } func newVisitor() *visitor { return &visitor{ FileSet: token.NewFileSet(), } } func (v *visitor) isConformanceCall(call *ast.CallExpr) bool { switch fun := call.Fun.(type) { case *ast.SelectorExpr: if fun.Sel != nil { return fun.Sel.Name == "ConformanceIt" } } return false } func (v *visitor) isLegacyItCall(call *ast.CallExpr) bool { switch fun := call.Fun.(type) { case *ast.Ident: if fun.Name != "It" { return false } if len(call.Args) < 1 { v.failf(call, "Not enough arguments to It()") } default: return false } switch arg := call.Args[0].(type) { case *ast.BasicLit: if arg.Kind != token.STRING { v.failf(arg, "Unexpected non-string argument to It()") } if strings.Contains(arg.Value, "[Conformance]") { return true } default: // non-literal argument to It()... we just ignore these even though they could be a way to "sneak in" a conformance test } return false } func (v *visitor) failf(expr ast.Expr, format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) fmt.Fprintf(os.Stderr, "ERROR at %v: %s\n", v.FileSet.Position(expr.Pos()), msg) } func (v *visitor) comment(x *ast.BasicLit) string { for _, comm := range v.cMap.Comments() { testOffset := int(x.Pos()-comm.End()) - len("framework.ConformanceIt(\"") if 0 < testOffset && testOffset < 3 { return comm.Text() } } return "" } func (v *visitor) emit(arg ast.Expr) { switch at := arg.(type) { case *ast.BasicLit: if at.Kind != token.STRING { v.failf(at, "framework.ConformanceIt() called with non-string argument") return } at.Value = normalizeTestName(at.Value) if *confDoc { v.convertToConformanceData(at) } else { fmt.Printf("%s: %q\n", v.FileSet.Position(at.Pos()).Filename, at.Value) } default: v.failf(at, "framework.ConformanceIt() called with non-literal argument") fmt.Fprintf(os.Stderr, "ERROR: non-literal argument %v at %v\n", arg, v.FileSet.Position(arg.Pos())) } } func (v *visitor) getDescription(value string) string { if len(v.lastDescribe.lastContext.text) > 0 { return strings.Trim(v.lastDescribe.text, "\"") + " " + strings.Trim(v.lastDescribe.lastContext.text, "\"") + " " + strings.Trim(value, "\"") } return strings.Trim(v.lastDescribe.text, "\"") + " " + strings.Trim(value, "\"") } var ( regexTag = regexp.MustCompile(`(\[[a-zA-Z0-9:-]+\])`) ) // normalizeTestName removes tags (e.g., [Feature:Foo]), double quotes and trim // the spaces to normalize the test name. func normalizeTestName(s string) string { r := regexTag.ReplaceAllString(s, "") r = strings.Trim(r, "\"") return strings.TrimSpace(r) } // funcName converts a selectorExpr with two idents into a string, // x.y -> "x.y" func funcName(n ast.Expr) string { if sel, ok := n.(*ast.SelectorExpr); ok { if x, ok := sel.X.(*ast.Ident); ok { return x.String() + "." + sel.Sel.String() } } return "" } // isSprintf returns whether the given node is a call to fmt.Sprintf func isSprintf(n ast.Expr) bool { call, ok := n.(*ast.CallExpr) return ok && funcName(call.Fun) == "fmt.Sprintf" && len(call.Args) != 0 } // firstArg attempts to statically determine the value of the first // argument. It only handles strings, and converts any unknown values // (fmt.Sprintf interpolations) into *. func (v *visitor) firstArg(n *ast.CallExpr) string { if len(n.Args) == 0 { return "" } var lit *ast.BasicLit if isSprintf(n.Args[0]) { return v.firstArg(n.Args[0].(*ast.CallExpr)) } lit, ok := n.Args[0].(*ast.BasicLit) if ok && lit.Kind == token.STRING { val, err := strconv.Unquote(lit.Value) if err != nil { panic(err) } if strings.Contains(val, "%") { val = strings.Replace(val, "%d", "*", -1) val = strings.Replace(val, "%v", "*", -1) val = strings.Replace(val, "%s", "*", -1) } return val } if ident, ok := n.Args[0].(*ast.Ident); ok { return ident.String() } return "*" } // matchFuncName returns the first argument of a function if it's // a Ginkgo-relevant function (Describe/KubeDescribe/Context), // and the empty string otherwise. func (v *visitor) matchFuncName(n *ast.CallExpr, pattern string) string { switch x := n.Fun.(type) { case *ast.SelectorExpr: if match, err := regexp.MatchString(pattern, x.Sel.Name); err == nil && match { return v.firstArg(n) } case *ast.Ident: if match, err := regexp.MatchString(pattern, x.Name); err == nil && match { return v.firstArg(n) } default: return "" } return "" } // Visit visits each node looking for either calls to framework.ConformanceIt, // which it will emit in its list of conformance tests, or legacy calls to // It() with a manually embedded [Conformance] tag, which it will complain // about. func (v *visitor) Visit(node ast.Node) (w ast.Visitor) { switch t := node.(type) { case *ast.CallExpr: if name := v.matchFuncName(t, regexDescribe); name != "" && len(t.Args) >= 2 { v.lastDescribe = describe{text: name} } else if name := v.matchFuncName(t, regexContext); name != "" && len(t.Args) >= 2 { v.lastDescribe.lastContext = context{text: name} } else if v.isConformanceCall(t) { totalConfTests++ v.emit(t.Args[0]) return nil } else if v.isLegacyItCall(t) { totalLegacyTests++ v.failf(t, "Using It() with manual [Conformance] tag is no longer allowed. Use framework.ConformanceIt() instead.") return nil } } return v } func scandir(dir string) { v := newVisitor() pkg, err := parser.ParseDir(v.FileSet, dir, nil, parser.ParseComments) if err != nil { panic(err) } for _, p := range pkg { ast.Walk(v, p) } } func scanfile(path string, src interface{}) []conformanceData { v := newVisitor() file, err := parser.ParseFile(v.FileSet, path, src, parser.ParseComments) if err != nil { panic(err) } v.cMap = ast.NewCommentMap(v.FileSet, file, file.Comments) ast.Walk(v, file) return v.tests } func main() { flag.Parse() if len(flag.Args()) < 1 { fmt.Fprintf(os.Stderr, "USAGE: %s