walk.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /*
  2. Copyright 2017 The Kubernetes Authors.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. // Package main provides a tool that scans kubernetes e2e test source code
  14. // looking for conformance test declarations, which it emits on stdout. It
  15. // also looks for legacy, manually added "[Conformance]" tags and reports an
  16. // error if it finds any.
  17. //
  18. // This approach is not air tight, but it will serve our purpose as a
  19. // pre-submit check.
  20. package main
  21. import (
  22. "flag"
  23. "fmt"
  24. "go/ast"
  25. "go/parser"
  26. "go/token"
  27. "io/ioutil"
  28. "os"
  29. "path/filepath"
  30. "regexp"
  31. "strconv"
  32. "strings"
  33. )
  34. var (
  35. baseURL = flag.String("url", "https://github.com/kubernetes/kubernetes/tree/master/", "location of the current source")
  36. confDoc = flag.Bool("conformance", false, "write a conformance document")
  37. totalConfTests, totalLegacyTests, missingComments int
  38. // If a test name contains any of these tags, it is ineligble for promotion to conformance
  39. regexIneligibleTags = regexp.MustCompile(`\[(Alpha|Disruptive|Feature:[^\]]+|Flaky)\]`)
  40. )
  41. const regexDescribe = "Describe|KubeDescribe|SIGDescribe"
  42. const regexContext = "Context"
  43. type visitor struct {
  44. FileSet *token.FileSet
  45. lastDescribe describe
  46. cMap ast.CommentMap
  47. //list of all the conformance tests in the path
  48. tests []conformanceData
  49. }
  50. //describe contains text associated with ginkgo describe container
  51. type describe struct {
  52. text string
  53. lastContext context
  54. }
  55. //context contain the text associated with the Context clause
  56. type context struct {
  57. text string
  58. }
  59. type conformanceData struct {
  60. // A URL to the line of code in the kube src repo for the test
  61. URL string
  62. // Extracted from the "Testname:" comment before the test
  63. TestName string
  64. // Extracted from the "Description:" comment before the test
  65. Description string
  66. // Version when this test is added or modified ex: v1.12, v1.13
  67. Release string
  68. }
  69. func (v *visitor) convertToConformanceData(at *ast.BasicLit) {
  70. cd := conformanceData{}
  71. comment := v.comment(at)
  72. pos := v.FileSet.Position(at.Pos())
  73. cd.URL = fmt.Sprintf("%s%s#L%d", *baseURL, pos.Filename, pos.Line)
  74. lines := strings.Split(comment, "\n")
  75. cd.Description = ""
  76. for _, line := range lines {
  77. line = strings.TrimSpace(line)
  78. if sline := regexp.MustCompile("^Testname\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  79. cd.TestName = sline[1]
  80. continue
  81. }
  82. if sline := regexp.MustCompile("^Release\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  83. cd.Release = sline[1]
  84. continue
  85. }
  86. if sline := regexp.MustCompile("^Description\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  87. line = sline[1]
  88. }
  89. cd.Description += line + "\n"
  90. }
  91. if cd.TestName == "" {
  92. testName := v.getDescription(at.Value)
  93. i := strings.Index(testName, "[Conformance]")
  94. if i > 0 {
  95. cd.TestName = strings.TrimSpace(testName[:i])
  96. } else {
  97. cd.TestName = testName
  98. }
  99. }
  100. v.tests = append(v.tests, cd)
  101. }
  102. func newVisitor() *visitor {
  103. return &visitor{
  104. FileSet: token.NewFileSet(),
  105. }
  106. }
  107. func (v *visitor) isConformanceCall(call *ast.CallExpr) bool {
  108. switch fun := call.Fun.(type) {
  109. case *ast.SelectorExpr:
  110. if fun.Sel != nil {
  111. return fun.Sel.Name == "ConformanceIt"
  112. }
  113. }
  114. return false
  115. }
  116. func (v *visitor) isLegacyItCall(call *ast.CallExpr) bool {
  117. switch fun := call.Fun.(type) {
  118. case *ast.Ident:
  119. if fun.Name != "It" {
  120. return false
  121. }
  122. if len(call.Args) < 1 {
  123. v.failf(call, "Not enough arguments to It()")
  124. }
  125. default:
  126. return false
  127. }
  128. switch arg := call.Args[0].(type) {
  129. case *ast.BasicLit:
  130. if arg.Kind != token.STRING {
  131. v.failf(arg, "Unexpected non-string argument to It()")
  132. }
  133. if strings.Contains(arg.Value, "[Conformance]") {
  134. return true
  135. }
  136. default:
  137. // non-literal argument to It()... we just ignore these even though they could be a way to "sneak in" a conformance test
  138. }
  139. return false
  140. }
  141. func (v *visitor) failf(expr ast.Expr, format string, a ...interface{}) {
  142. msg := fmt.Sprintf(format, a...)
  143. fmt.Fprintf(os.Stderr, "ERROR at %v: %s\n", v.FileSet.Position(expr.Pos()), msg)
  144. }
  145. func (v *visitor) comment(x *ast.BasicLit) string {
  146. for _, comm := range v.cMap.Comments() {
  147. testOffset := int(x.Pos()-comm.End()) - len("framework.ConformanceIt(\"")
  148. //Cannot assume the offset is within three or four tabs from the test block itself.
  149. //It is better to trim the newlines, tabs, etc and then we if the comment is followed
  150. //by the test block itself so that we can associate the comment with it properly.
  151. if 0 <= testOffset && testOffset <= 10 {
  152. b1 := make([]byte, x.Pos()-comm.End())
  153. //if we fail to open the file to compare the content we just assume the
  154. //proximity of the comment and apply it.
  155. myf, err := os.Open(v.FileSet.File(x.Pos()).Name())
  156. if err == nil {
  157. if _, err := myf.Seek(int64(comm.End()), 0); err == nil {
  158. if _, err := myf.Read(b1); err == nil {
  159. if strings.Compare(strings.Trim(string(b1), "\t \r\n"), "framework.ConformanceIt(\"") == 0 {
  160. return comm.Text()
  161. }
  162. }
  163. }
  164. } else {
  165. //comment section's end is noticed within 10 characters from framework.ConformanceIt block
  166. return comm.Text()
  167. }
  168. }
  169. }
  170. return ""
  171. }
  172. func (v *visitor) emit(arg ast.Expr) {
  173. switch at := arg.(type) {
  174. case *ast.BasicLit:
  175. if at.Kind != token.STRING {
  176. v.failf(at, "framework.ConformanceIt() called with non-string argument")
  177. return
  178. }
  179. err := validateTestName(v.getDescription(at.Value))
  180. if err != nil {
  181. v.failf(at, err.Error())
  182. return
  183. }
  184. at.Value = normalizeTestName(at.Value)
  185. if *confDoc {
  186. v.convertToConformanceData(at)
  187. } else {
  188. fmt.Printf("%s: %q\n", v.FileSet.Position(at.Pos()).Filename, at.Value)
  189. }
  190. default:
  191. v.failf(at, "framework.ConformanceIt() called with non-literal argument")
  192. fmt.Fprintf(os.Stderr, "ERROR: non-literal argument %v at %v\n", arg, v.FileSet.Position(arg.Pos()))
  193. }
  194. }
  195. func (v *visitor) getDescription(value string) string {
  196. if len(v.lastDescribe.lastContext.text) > 0 {
  197. return strings.Trim(v.lastDescribe.text, "\"") +
  198. " " + strings.Trim(v.lastDescribe.lastContext.text, "\"") +
  199. " " + strings.Trim(value, "\"")
  200. }
  201. return strings.Trim(v.lastDescribe.text, "\"") +
  202. " " + strings.Trim(value, "\"")
  203. }
  204. var (
  205. regexTag = regexp.MustCompile(`(\[[a-zA-Z0-9:-]+\])`)
  206. )
  207. // normalizeTestName removes tags (e.g., [Feature:Foo]), double quotes and trim
  208. // the spaces to normalize the test name.
  209. func normalizeTestName(s string) string {
  210. r := regexTag.ReplaceAllString(s, "")
  211. r = strings.Trim(r, "\"")
  212. return strings.TrimSpace(r)
  213. }
  214. func validateTestName(s string) error {
  215. matches := regexIneligibleTags.FindAllString(s, -1)
  216. if matches != nil {
  217. return fmt.Errorf("'%s' cannot have invalid tags %v", s, strings.Join(matches, ","))
  218. }
  219. return nil
  220. }
  221. // funcName converts a selectorExpr with two idents into a string,
  222. // x.y -> "x.y"
  223. func funcName(n ast.Expr) string {
  224. if sel, ok := n.(*ast.SelectorExpr); ok {
  225. if x, ok := sel.X.(*ast.Ident); ok {
  226. return x.String() + "." + sel.Sel.String()
  227. }
  228. }
  229. return ""
  230. }
  231. // isSprintf returns whether the given node is a call to fmt.Sprintf
  232. func isSprintf(n ast.Expr) bool {
  233. call, ok := n.(*ast.CallExpr)
  234. return ok && funcName(call.Fun) == "fmt.Sprintf" && len(call.Args) != 0
  235. }
  236. // firstArg attempts to statically determine the value of the first
  237. // argument. It only handles strings, and converts any unknown values
  238. // (fmt.Sprintf interpolations) into *.
  239. func (v *visitor) firstArg(n *ast.CallExpr) string {
  240. if len(n.Args) == 0 {
  241. return ""
  242. }
  243. var lit *ast.BasicLit
  244. if isSprintf(n.Args[0]) {
  245. return v.firstArg(n.Args[0].(*ast.CallExpr))
  246. }
  247. lit, ok := n.Args[0].(*ast.BasicLit)
  248. if ok && lit.Kind == token.STRING {
  249. val, err := strconv.Unquote(lit.Value)
  250. if err != nil {
  251. panic(err)
  252. }
  253. if strings.Contains(val, "%") {
  254. val = strings.Replace(val, "%d", "*", -1)
  255. val = strings.Replace(val, "%v", "*", -1)
  256. val = strings.Replace(val, "%s", "*", -1)
  257. }
  258. return val
  259. }
  260. if ident, ok := n.Args[0].(*ast.Ident); ok {
  261. return ident.String()
  262. }
  263. return "*"
  264. }
  265. // matchFuncName returns the first argument of a function if it's
  266. // a Ginkgo-relevant function (Describe/KubeDescribe/Context),
  267. // and the empty string otherwise.
  268. func (v *visitor) matchFuncName(n *ast.CallExpr, pattern string) string {
  269. switch x := n.Fun.(type) {
  270. case *ast.SelectorExpr:
  271. if match, err := regexp.MatchString(pattern, x.Sel.Name); err == nil && match {
  272. return v.firstArg(n)
  273. }
  274. case *ast.Ident:
  275. if match, err := regexp.MatchString(pattern, x.Name); err == nil && match {
  276. return v.firstArg(n)
  277. }
  278. default:
  279. return ""
  280. }
  281. return ""
  282. }
  283. // Visit visits each node looking for either calls to framework.ConformanceIt,
  284. // which it will emit in its list of conformance tests, or legacy calls to
  285. // It() with a manually embedded [Conformance] tag, which it will complain
  286. // about.
  287. func (v *visitor) Visit(node ast.Node) (w ast.Visitor) {
  288. switch t := node.(type) {
  289. case *ast.CallExpr:
  290. if name := v.matchFuncName(t, regexDescribe); name != "" && len(t.Args) >= 2 {
  291. v.lastDescribe = describe{text: name}
  292. } else if name := v.matchFuncName(t, regexContext); name != "" && len(t.Args) >= 2 {
  293. v.lastDescribe.lastContext = context{text: name}
  294. } else if v.isConformanceCall(t) {
  295. totalConfTests++
  296. v.emit(t.Args[0])
  297. return nil
  298. } else if v.isLegacyItCall(t) {
  299. totalLegacyTests++
  300. v.failf(t, "Using It() with manual [Conformance] tag is no longer allowed. Use framework.ConformanceIt() instead.")
  301. return nil
  302. }
  303. }
  304. return v
  305. }
  306. func scanfile(path string, src interface{}) []conformanceData {
  307. v := newVisitor()
  308. file, err := parser.ParseFile(v.FileSet, path, src, parser.ParseComments)
  309. if err != nil {
  310. panic(err)
  311. }
  312. v.cMap = ast.NewCommentMap(v.FileSet, file, file.Comments)
  313. ast.Walk(v, file)
  314. return v.tests
  315. }
  316. func main() {
  317. flag.Parse()
  318. if len(flag.Args()) < 1 {
  319. fmt.Fprintf(os.Stderr, "USAGE: %s <DIR or FILE> [...]\n", os.Args[0])
  320. os.Exit(64)
  321. }
  322. if *confDoc {
  323. // Note: this assumes that you're running from the root of the kube src repo
  324. header, err := ioutil.ReadFile("test/conformance/cf_header.md")
  325. if err == nil {
  326. fmt.Printf("%s\n\n", header)
  327. }
  328. }
  329. totalConfTests = 0
  330. totalLegacyTests = 0
  331. missingComments = 0
  332. for _, arg := range flag.Args() {
  333. filepath.Walk(arg, func(path string, info os.FileInfo, err error) error {
  334. if err != nil {
  335. return err
  336. }
  337. if strings.HasSuffix(path, ".go") {
  338. tests := scanfile(path, nil)
  339. for _, cd := range tests {
  340. fmt.Printf("## [%s](%s)\n\n", cd.TestName, cd.URL)
  341. fmt.Printf("### Release %s\n", cd.Release)
  342. fmt.Printf("%s\n\n", cd.Description)
  343. if len(cd.Description) < 10 {
  344. missingComments++
  345. }
  346. }
  347. }
  348. return nil
  349. })
  350. }
  351. if *confDoc {
  352. fmt.Println("\n## **Summary**")
  353. fmt.Printf("\nTotal Conformance Tests: %d, total legacy tests that need conversion: %d, while total tests that need comment sections: %d\n\n", totalConfTests, totalLegacyTests, missingComments)
  354. }
  355. }