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