validate.go raw

   1  package cartography
   2  
   3  import (
   4  	"go/ast"
   5  	"go/parser"
   6  	"go/token"
   7  	"os"
   8  	"path/filepath"
   9  	"strings"
  10  )
  11  
  12  // ExtractTests walks test files and associates test functions with the
  13  // declarations they test. Updates HasTest, TestFuncs, TestFile, TestPatterns,
  14  // Validation, and bumps Confidence from 0.3 → 0.5 for entries with tests.
  15  func ExtractTests(atlas *Atlas, projectRoot string) error {
  16  	return filepath.Walk(projectRoot, func(path string, info os.FileInfo, err error) error {
  17  		if err != nil {
  18  			return nil
  19  		}
  20  		if info.IsDir() {
  21  			if skipDirs[info.Name()] {
  22  				return filepath.SkipDir
  23  			}
  24  			return nil
  25  		}
  26  		if !strings.HasSuffix(path, "_test.go") {
  27  			return nil
  28  		}
  29  
  30  		rel, _ := filepath.Rel(projectRoot, path)
  31  		extractTestFile(atlas, path, rel)
  32  		return nil
  33  	})
  34  }
  35  
  36  // extractTestFile parses a single test file and associates its test functions
  37  // with atlas entries.
  38  func extractTestFile(atlas *Atlas, absPath, relPath string) {
  39  	fset := token.NewFileSet()
  40  	file, err := parser.ParseFile(fset, absPath, nil, parser.ParseComments)
  41  	if err != nil {
  42  		return
  43  	}
  44  
  45  	pkgName := ""
  46  	if file.Name != nil {
  47  		pkgName = file.Name.Name
  48  	}
  49  
  50  	for _, decl := range file.Decls {
  51  		fn, ok := decl.(*ast.FuncDecl)
  52  		if !ok {
  53  			continue
  54  		}
  55  		name := fn.Name.Name
  56  		if !strings.HasPrefix(name, "Test") {
  57  			continue
  58  		}
  59  
  60  		// Match test to declaration.
  61  		target := matchTestToDecl(name, pkgName, atlas)
  62  		if target == nil {
  63  			continue
  64  		}
  65  
  66  		target.HasTest = true
  67  		target.TestFuncs = appendUnique(target.TestFuncs, name)
  68  		if target.TestFile == "" {
  69  			target.TestFile = relPath
  70  		}
  71  
  72  		// Extract test table case names.
  73  		patterns := extractTestTableNames(fn.Body)
  74  		for _, p := range patterns {
  75  			target.TestPatterns = appendUnique(target.TestPatterns, p)
  76  		}
  77  
  78  		// Update validation text.
  79  		target.Validation = buildValidation(target)
  80  
  81  		// Bump confidence.
  82  		if target.Source == "mechanical" && target.Confidence < 0.5 {
  83  			target.Confidence = 0.5
  84  		}
  85  	}
  86  }
  87  
  88  // matchTestToDecl tries to find the atlas entry that a test function tests.
  89  // Uses naming conventions:
  90  //
  91  //	TestFoo       → matches func Foo or type Foo
  92  //	TestFoo_Bar   → matches func Foo (sub-test "Bar")
  93  //	TestType_Foo  → matches method Type.Foo
  94  func matchTestToDecl(testName, pkg string, atlas *Atlas) *Entry {
  95  	// Strip "Test" prefix.
  96  	rest := strings.TrimPrefix(testName, "Test")
  97  	if rest == "" {
  98  		return nil
  99  	}
 100  
 101  	// Strip leading underscore (Test_foo convention for unexported).
 102  	rest = strings.TrimPrefix(rest, "_")
 103  
 104  	// Split on underscore — first part is the target, rest are sub-test names.
 105  	parts := strings.SplitN(rest, "_", 2)
 106  	target := parts[0]
 107  
 108  	// Try direct function match: pkg.Target
 109  	if e, ok := atlas.Entries[pkg+"."+target]; ok {
 110  		return e
 111  	}
 112  
 113  	// Try method match: pkg.Target.SubPart (e.g., TestNode_Bond → lattice.Node.Bond)
 114  	if len(parts) > 1 {
 115  		methodName := parts[1]
 116  		if e, ok := atlas.Entries[pkg+"."+target+"."+methodName]; ok {
 117  			return e
 118  		}
 119  	}
 120  
 121  	// Search by bare name.
 122  	for _, e := range atlas.Entries {
 123  		if e.Package == pkg && e.Name == target {
 124  			return e
 125  		}
 126  	}
 127  
 128  	return nil
 129  }
 130  
 131  // extractTestTableNames looks for table-driven test patterns inside a test
 132  // function body. Scans for composite literals with a "name" or "desc" field
 133  // that has string literal values.
 134  func extractTestTableNames(body *ast.BlockStmt) []string {
 135  	if body == nil {
 136  		return nil
 137  	}
 138  
 139  	var names []string
 140  
 141  	ast.Inspect(body, func(n ast.Node) bool {
 142  		cl, ok := n.(*ast.CompositeLit)
 143  		if !ok {
 144  			return true
 145  		}
 146  
 147  		// Look for struct literals with a "name" or "desc" field.
 148  		for _, elt := range cl.Elts {
 149  			kv, ok := elt.(*ast.KeyValueExpr)
 150  			if !ok {
 151  				continue
 152  			}
 153  			ident, ok := kv.Key.(*ast.Ident)
 154  			if !ok {
 155  				continue
 156  			}
 157  			keyName := strings.ToLower(ident.Name)
 158  			if keyName != "name" && keyName != "desc" && keyName != "description" &&
 159  				keyName != "label" && keyName != "in" {
 160  				continue
 161  			}
 162  			bl, ok := kv.Value.(*ast.BasicLit)
 163  			if !ok || bl.Kind != token.STRING {
 164  				continue
 165  			}
 166  			// Strip quotes.
 167  			val := strings.Trim(bl.Value, `"` + "`")
 168  			if val != "" {
 169  				names = append(names, val)
 170  			}
 171  		}
 172  		return true
 173  	})
 174  
 175  	return names
 176  }
 177  
 178  // buildValidation generates a human-readable validation description.
 179  func buildValidation(e *Entry) string {
 180  	if len(e.TestFuncs) == 0 {
 181  		return ""
 182  	}
 183  
 184  	var b strings.Builder
 185  	b.WriteString("Tested by ")
 186  	b.WriteString(strings.Join(e.TestFuncs, ", "))
 187  	b.WriteString(".")
 188  
 189  	if len(e.TestPatterns) > 0 {
 190  		b.WriteString(" Cases: ")
 191  		// Show at most 5 patterns.
 192  		n := len(e.TestPatterns)
 193  		if n > 5 {
 194  			n = 5
 195  		}
 196  		b.WriteString(strings.Join(e.TestPatterns[:n], ", "))
 197  		if len(e.TestPatterns) > 5 {
 198  			b.WriteString(" ...")
 199  		}
 200  		b.WriteString(".")
 201  	}
 202  
 203  	return b.String()
 204  }
 205  
 206  // appendUnique appends a string to a slice only if not already present.
 207  func appendUnique(slice []string, s string) []string {
 208  	for _, existing := range slice {
 209  		if existing == s {
 210  			return slice
 211  		}
 212  	}
 213  	return append(slice, s)
 214  }
 215