2018-01-09 18:57:14 +00:00
/ *
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 (
2018-03-06 22:33:18 +00:00
"flag"
2018-01-09 18:57:14 +00:00
"fmt"
"go/ast"
"go/parser"
"go/token"
2018-03-06 22:33:18 +00:00
"io/ioutil"
2018-01-09 18:57:14 +00:00
"os"
"path/filepath"
2018-03-06 22:33:18 +00:00
"regexp"
"strconv"
2018-01-09 18:57:14 +00:00
"strings"
)
2018-03-06 22:33:18 +00:00
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"
2018-01-09 18:57:14 +00:00
type visitor struct {
2018-03-06 22:33:18 +00:00
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 )
2018-01-09 18:57:14 +00:00
}
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 )
2018-03-06 22:33:18 +00:00
}
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 ""
2018-01-09 18:57:14 +00:00
}
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
}
2018-03-06 22:33:18 +00:00
2018-07-18 14:47:22 +00:00
at . Value = normalizeTestName ( at . Value )
2018-03-06 22:33:18 +00:00
if * confDoc {
v . convertToConformanceData ( at )
} else {
2018-07-18 14:47:22 +00:00
fmt . Printf ( "%s: %q\n" , v . FileSet . Position ( at . Pos ( ) ) . Filename , at . Value )
2018-03-06 22:33:18 +00:00
}
2018-01-09 18:57:14 +00:00
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 ( ) ) )
}
}
2018-03-06 22:33:18 +00:00
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 , "\"" )
}
2018-07-18 14:47:22 +00:00
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 )
}
2018-03-06 22:33:18 +00:00
// 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 ""
}
2018-01-09 18:57:14 +00:00
// 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 :
2018-03-06 22:33:18 +00:00
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 ++
2018-01-09 18:57:14 +00:00
v . emit ( t . Args [ 0 ] )
2018-03-06 22:33:18 +00:00
return nil
2018-01-09 18:57:14 +00:00
} else if v . isLegacyItCall ( t ) {
2018-03-06 22:33:18 +00:00
totalLegacyTests ++
2018-01-09 18:57:14 +00:00
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 ( )
2018-03-06 22:33:18 +00:00
pkg , err := parser . ParseDir ( v . FileSet , dir , nil , parser . ParseComments )
2018-01-09 18:57:14 +00:00
if err != nil {
panic ( err )
}
for _ , p := range pkg {
ast . Walk ( v , p )
}
}
2018-03-06 22:33:18 +00:00
func scanfile ( path string , src interface { } ) [ ] conformanceData {
2018-01-09 18:57:14 +00:00
v := newVisitor ( )
2018-03-06 22:33:18 +00:00
file , err := parser . ParseFile ( v . FileSet , path , src , parser . ParseComments )
2018-01-09 18:57:14 +00:00
if err != nil {
panic ( err )
}
2018-03-06 22:33:18 +00:00
v . cMap = ast . NewCommentMap ( v . FileSet , file , file . Comments )
2018-01-09 18:57:14 +00:00
ast . Walk ( v , file )
2018-03-06 22:33:18 +00:00
return v . tests
2018-01-09 18:57:14 +00:00
}
func main ( ) {
2018-03-06 22:33:18 +00:00
flag . Parse ( )
if len ( flag . Args ( ) ) < 1 {
2018-01-09 18:57:14 +00:00
fmt . Fprintf ( os . Stderr , "USAGE: %s <DIR or FILE> [...]\n" , os . Args [ 0 ] )
os . Exit ( 64 )
}
2018-03-06 22:33:18 +00:00
if * confDoc {
// Note: this assumes that you're running from the root of the kube src repo
header , err := ioutil . ReadFile ( "test/conformance/cf_header.md" )
if err == nil {
fmt . Printf ( "%s\n\n" , header )
}
}
totalConfTests = 0
totalLegacyTests = 0
missingComments = 0
for _ , arg := range flag . Args ( ) {
2018-01-09 18:57:14 +00:00
filepath . Walk ( arg , func ( path string , info os . FileInfo , err error ) error {
if err != nil {
return err
}
2018-03-06 22:33:18 +00:00
if strings . HasSuffix ( path , ".go" ) {
tests := scanfile ( path , nil )
for _ , cd := range tests {
fmt . Printf ( "## [%s](%s)\n\n" , cd . TestName , cd . URL )
fmt . Printf ( "%s\n\n" , cd . Description )
if len ( cd . Description ) < 10 {
missingComments ++
}
2018-01-09 18:57:14 +00:00
}
}
return nil
} )
}
2018-03-06 22:33:18 +00:00
if * confDoc {
fmt . Println ( "\n## **Summary**" )
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 )
}
2018-01-09 18:57:14 +00:00
}