walk.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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. "os"
  28. "path/filepath"
  29. "regexp"
  30. "strconv"
  31. "strings"
  32. "text/template"
  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. version = flag.String("version", "v1.9", "version of this conformance document")
  38. totalConfTests, totalLegacyTests, missingComments int
  39. // If a test name contains any of these tags, it is ineligble for promotion to conformance
  40. regexIneligibleTags = regexp.MustCompile(`\[(Alpha|Feature:[^\]]+|Flaky)\]`)
  41. )
  42. const regexDescribe = "Describe|KubeDescribe|SIGDescribe"
  43. const regexContext = "^Context$"
  44. type visitor struct {
  45. FileSet *token.FileSet
  46. describes []describe
  47. cMap ast.CommentMap
  48. //list of all the conformance tests in the path
  49. tests []conformanceData
  50. }
  51. //describe contains text associated with ginkgo describe container
  52. type describe struct {
  53. rparen token.Pos
  54. text string
  55. lastContext context
  56. }
  57. //context contain the text associated with the Context clause
  58. type context struct {
  59. text string
  60. }
  61. type conformanceData struct {
  62. // A URL to the line of code in the kube src repo for the test
  63. URL string
  64. // Extracted from the "Testname:" comment before the test
  65. TestName string
  66. // Extracted from the "Description:" comment before the test
  67. Description string
  68. // Version when this test is added or modified ex: v1.12, v1.13
  69. Release string
  70. }
  71. func (v *visitor) convertToConformanceData(at *ast.BasicLit) {
  72. cd := conformanceData{}
  73. comment := v.comment(at)
  74. pos := v.FileSet.Position(at.Pos())
  75. cd.URL = fmt.Sprintf("%s%s#L%d", *baseURL, pos.Filename, pos.Line)
  76. lines := strings.Split(comment, "\n")
  77. cd.Description = ""
  78. for _, line := range lines {
  79. line = strings.TrimSpace(line)
  80. if sline := regexp.MustCompile("^Testname\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  81. cd.TestName = sline[1]
  82. continue
  83. }
  84. if sline := regexp.MustCompile("^Release\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  85. cd.Release = sline[1]
  86. continue
  87. }
  88. if sline := regexp.MustCompile("^Description\\s*:\\s*").Split(line, -1); len(sline) == 2 {
  89. line = sline[1]
  90. }
  91. cd.Description += line + "\n"
  92. }
  93. if cd.TestName == "" {
  94. testName := v.getDescription(at.Value)
  95. i := strings.Index(testName, "[Conformance]")
  96. if i > 0 {
  97. cd.TestName = strings.TrimSpace(testName[:i])
  98. } else {
  99. cd.TestName = testName
  100. }
  101. }
  102. v.tests = append(v.tests, cd)
  103. }
  104. func newVisitor() *visitor {
  105. return &visitor{
  106. FileSet: token.NewFileSet(),
  107. }
  108. }
  109. func (v *visitor) isConformanceCall(call *ast.CallExpr) bool {
  110. switch fun := call.Fun.(type) {
  111. case *ast.SelectorExpr:
  112. if fun.Sel != nil {
  113. return fun.Sel.Name == "ConformanceIt"
  114. }
  115. }
  116. return false
  117. }
  118. func (v *visitor) isLegacyItCall(call *ast.CallExpr) bool {
  119. switch fun := call.Fun.(type) {
  120. case *ast.Ident:
  121. if fun.Name != "It" {
  122. return false
  123. }
  124. if len(call.Args) < 1 {
  125. v.failf(call, "Not enough arguments to It()")
  126. }
  127. default:
  128. return false
  129. }
  130. switch arg := call.Args[0].(type) {
  131. case *ast.BasicLit:
  132. if arg.Kind != token.STRING {
  133. v.failf(arg, "Unexpected non-string argument to It()")
  134. }
  135. if strings.Contains(arg.Value, "[Conformance]") {
  136. return true
  137. }
  138. default:
  139. // non-literal argument to It()... we just ignore these even though they could be a way to "sneak in" a conformance test
  140. }
  141. return false
  142. }
  143. func (v *visitor) failf(expr ast.Expr, format string, a ...interface{}) {
  144. msg := fmt.Sprintf(format, a...)
  145. fmt.Fprintf(os.Stderr, "ERROR at %v: %s\n", v.FileSet.Position(expr.Pos()), msg)
  146. }
  147. func (v *visitor) comment(x *ast.BasicLit) string {
  148. for _, comm := range v.cMap.Comments() {
  149. testOffset := int(x.Pos()-comm.End()) - len("framework.ConformanceIt(\"")
  150. //Cannot assume the offset is within three or four tabs from the test block itself.
  151. //It is better to trim the newlines, tabs, etc and then we if the comment is followed
  152. //by the test block itself so that we can associate the comment with it properly.
  153. if 0 <= testOffset && testOffset <= 10 {
  154. b1 := make([]byte, x.Pos()-comm.End())
  155. //if we fail to open the file to compare the content we just assume the
  156. //proximity of the comment and apply it.
  157. myf, err := os.Open(v.FileSet.File(x.Pos()).Name())
  158. if err == nil {
  159. defer myf.Close()
  160. if _, err := myf.Seek(int64(comm.End()), 0); err == nil {
  161. if _, err := myf.Read(b1); err == nil {
  162. if strings.Compare(strings.Trim(string(b1), "\t \r\n"), "framework.ConformanceIt(\"") == 0 {
  163. return comm.Text()
  164. }
  165. }
  166. }
  167. } else {
  168. //comment section's end is noticed within 10 characters from framework.ConformanceIt block
  169. return comm.Text()
  170. }
  171. }
  172. }
  173. return ""
  174. }
  175. func (v *visitor) emit(arg ast.Expr) {
  176. switch at := arg.(type) {
  177. case *ast.BasicLit:
  178. if at.Kind != token.STRING {
  179. v.failf(at, "framework.ConformanceIt() called with non-string argument")
  180. return
  181. }
  182. description := v.getDescription(at.Value)
  183. err := validateTestName(description)
  184. if err != nil {
  185. v.failf(at, err.Error())
  186. return
  187. }
  188. at.Value = normalizeTestName(at.Value)
  189. if *confDoc {
  190. v.convertToConformanceData(at)
  191. } else {
  192. fmt.Printf("%s: %q\n", v.FileSet.Position(at.Pos()).Filename, at.Value)
  193. }
  194. default:
  195. v.failf(at, "framework.ConformanceIt() called with non-literal argument")
  196. fmt.Fprintf(os.Stderr, "ERROR: non-literal argument %v at %v\n", arg, v.FileSet.Position(arg.Pos()))
  197. }
  198. }
  199. func (v *visitor) getDescription(value string) string {
  200. tokens := []string{}
  201. for _, describe := range v.describes {
  202. tokens = append(tokens, describe.text)
  203. if len(describe.lastContext.text) > 0 {
  204. tokens = append(tokens, describe.lastContext.text)
  205. }
  206. }
  207. tokens = append(tokens, value)
  208. trimmed := []string{}
  209. for _, token := range tokens {
  210. trimmed = append(trimmed, strings.Trim(token, "\""))
  211. }
  212. return strings.Join(trimmed, " ")
  213. }
  214. var (
  215. regexTag = regexp.MustCompile(`(\[[a-zA-Z0-9:-]+\])`)
  216. )
  217. // normalizeTestName removes tags (e.g., [Feature:Foo]), double quotes and trim
  218. // the spaces to normalize the test name.
  219. func normalizeTestName(s string) string {
  220. r := regexTag.ReplaceAllString(s, "")
  221. r = strings.Trim(r, "\"")
  222. return strings.TrimSpace(r)
  223. }
  224. func validateTestName(s string) error {
  225. matches := regexIneligibleTags.FindAllString(s, -1)
  226. if matches != nil {
  227. return fmt.Errorf("'%s' cannot have invalid tags %v", s, strings.Join(matches, ","))
  228. }
  229. return nil
  230. }
  231. // funcName converts a selectorExpr with two idents into a string,
  232. // x.y -> "x.y"
  233. func funcName(n ast.Expr) string {
  234. if sel, ok := n.(*ast.SelectorExpr); ok {
  235. if x, ok := sel.X.(*ast.Ident); ok {
  236. return x.String() + "." + sel.Sel.String()
  237. }
  238. }
  239. return ""
  240. }
  241. // isSprintf returns whether the given node is a call to fmt.Sprintf
  242. func isSprintf(n ast.Expr) bool {
  243. call, ok := n.(*ast.CallExpr)
  244. return ok && funcName(call.Fun) == "fmt.Sprintf" && len(call.Args) != 0
  245. }
  246. // firstArg attempts to statically determine the value of the first
  247. // argument. It only handles strings, and converts any unknown values
  248. // (fmt.Sprintf interpolations) into *.
  249. func (v *visitor) firstArg(n *ast.CallExpr) string {
  250. if len(n.Args) == 0 {
  251. return ""
  252. }
  253. var lit *ast.BasicLit
  254. if isSprintf(n.Args[0]) {
  255. return v.firstArg(n.Args[0].(*ast.CallExpr))
  256. }
  257. lit, ok := n.Args[0].(*ast.BasicLit)
  258. if ok && lit.Kind == token.STRING {
  259. val, err := strconv.Unquote(lit.Value)
  260. if err != nil {
  261. panic(err)
  262. }
  263. if strings.Contains(val, "%") {
  264. val = strings.Replace(val, "%d", "*", -1)
  265. val = strings.Replace(val, "%v", "*", -1)
  266. val = strings.Replace(val, "%s", "*", -1)
  267. }
  268. return val
  269. }
  270. if ident, ok := n.Args[0].(*ast.Ident); ok {
  271. return ident.String()
  272. }
  273. return "*"
  274. }
  275. // matchFuncName returns the first argument of a function if it's
  276. // a Ginkgo-relevant function (Describe/KubeDescribe/Context),
  277. // and the empty string otherwise.
  278. func (v *visitor) matchFuncName(n *ast.CallExpr, pattern string) string {
  279. switch x := n.Fun.(type) {
  280. case *ast.SelectorExpr:
  281. if match, err := regexp.MatchString(pattern, x.Sel.Name); err == nil && match {
  282. return v.firstArg(n)
  283. }
  284. case *ast.Ident:
  285. if match, err := regexp.MatchString(pattern, x.Name); err == nil && match {
  286. return v.firstArg(n)
  287. }
  288. default:
  289. return ""
  290. }
  291. return ""
  292. }
  293. // Visit visits each node looking for either calls to framework.ConformanceIt,
  294. // which it will emit in its list of conformance tests, or legacy calls to
  295. // It() with a manually embedded [Conformance] tag, which it will complain
  296. // about.
  297. func (v *visitor) Visit(node ast.Node) (w ast.Visitor) {
  298. lastDescribe := len(v.describes) - 1
  299. switch t := node.(type) {
  300. case *ast.CallExpr:
  301. if name := v.matchFuncName(t, regexDescribe); name != "" && len(t.Args) >= 2 {
  302. v.describes = append(v.describes, describe{text: name, rparen: t.Rparen})
  303. } else if name := v.matchFuncName(t, regexContext); name != "" && len(t.Args) >= 2 {
  304. if lastDescribe > -1 {
  305. v.describes[lastDescribe].lastContext = context{text: name}
  306. }
  307. } else if v.isConformanceCall(t) {
  308. totalConfTests++
  309. v.emit(t.Args[0])
  310. return nil
  311. } else if v.isLegacyItCall(t) {
  312. totalLegacyTests++
  313. v.failf(t, "Using It() with manual [Conformance] tag is no longer allowed. Use framework.ConformanceIt() instead.")
  314. return nil
  315. }
  316. }
  317. // If we're past the position of the last describe's rparen, pop the describe off
  318. if lastDescribe > -1 && node != nil {
  319. if node.Pos() > v.describes[lastDescribe].rparen {
  320. v.describes = v.describes[:lastDescribe]
  321. }
  322. }
  323. return v
  324. }
  325. func scanfile(path string, src interface{}) []conformanceData {
  326. v := newVisitor()
  327. file, err := parser.ParseFile(v.FileSet, path, src, parser.ParseComments)
  328. if err != nil {
  329. panic(err)
  330. }
  331. v.cMap = ast.NewCommentMap(v.FileSet, file, file.Comments)
  332. ast.Walk(v, file)
  333. return v.tests
  334. }
  335. func main() {
  336. flag.Parse()
  337. if len(flag.Args()) < 1 {
  338. fmt.Fprintf(os.Stderr, "USAGE: %s <DIR or FILE> [...]\n", os.Args[0])
  339. os.Exit(64)
  340. }
  341. if *confDoc {
  342. // Note: this assumes that you're running from the root of the kube src repo
  343. templ, err := template.ParseFiles("test/conformance/cf_header.md")
  344. if err != nil {
  345. fmt.Printf("Error reading the Header file information: %s\n\n", err)
  346. }
  347. data := struct {
  348. Version string
  349. }{
  350. Version: *version,
  351. }
  352. templ.Execute(os.Stdout, data)
  353. }
  354. totalConfTests = 0
  355. totalLegacyTests = 0
  356. missingComments = 0
  357. for _, arg := range flag.Args() {
  358. filepath.Walk(arg, func(path string, info os.FileInfo, err error) error {
  359. if err != nil {
  360. return err
  361. }
  362. if strings.HasSuffix(path, ".go") {
  363. tests := scanfile(path, nil)
  364. for _, cd := range tests {
  365. fmt.Printf("## [%s](%s)\n\n", cd.TestName, cd.URL)
  366. fmt.Printf("### Release %s\n", cd.Release)
  367. fmt.Printf("%s\n\n", cd.Description)
  368. if len(cd.Description) < 10 {
  369. missingComments++
  370. }
  371. }
  372. }
  373. return nil
  374. })
  375. }
  376. if *confDoc {
  377. fmt.Println("\n## **Summary**")
  378. 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)
  379. }
  380. }