gaming.odin (9275B)
1 package check 2 3 // The anti-gaming checks hold the review to itself: a change that 4 // dismisses what the reading would find, or deletes the tests that would 5 // have failed, is measured rather than waved through. 6 7 import "core:fmt" 8 import "core:strings" 9 10 import "../change" 11 import "../finding" 12 import "../txt" 13 14 // rule_id is what a dismissal's rule id has to look like. The prose that 15 // documents the mechanism writes placeholders (<rule>) and quoted 16 // examples, which match the ignore pattern but dismiss nothing. 17 rule_id :: `^[a-z][a-z0-9-]*$` 18 19 // check_suppression_added reports a change that adds its own dismissal, 20 // before the readers have run. The finding names no file, so it cannot 21 // be dismissed in turn. 22 check_suppression_added :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 23 spots := make([dynamic]string, context.temp_allocator) 24 for file in sorted_keys(s.c.added) { 25 if !change.is_code_file(file) { 26 continue 27 } 28 lines := s.c.added[file] 29 for l in lines { 30 rule, _, found := finding.dismissal(l.text) 31 if !found || !matches(rule_id, rule) { 32 continue 33 } 34 append(&spots, fmt.tprintf("%s:%d (%s)", file, l.line, rule)) 35 } 36 } 37 if len(spots) == 0 { 38 return 39 } 40 append( 41 out, 42 static( 43 "suppression-added", 44 .Must_Fix, 45 fmt.aprintf( 46 "the change adds its own dismissal%s: %s; a dismissal is a decision for the person reviewing the change, and a review that dismisses itself cannot be failed", 47 plural(len(spots)), 48 strings.join(spots[:], "; ", context.temp_allocator), 49 ), 50 "correct what the dismissal covers; a dismissal belongs to the person reviewing, who accepts it deliberately", 51 ), 52 ) 53 } 54 55 // dismissals counts the dismissals in the tree per rule, with where each 56 // is and why, so that the rules people argue with are visible: a rule 57 // dismissed everywhere is a rule to rewrite. 58 dismissals :: proc(s: Scope, allocator := context.allocator) -> string { 59 Spot :: struct { 60 where_at, why: string, 61 } 62 by_rule := make(map[string][dynamic]Spot, context.temp_allocator) 63 for file in sorted_keys(s.sources) { 64 if !change.is_code_file(file) { 65 continue 66 } 67 rest := string(s.sources[file]) 68 number := 0 69 for line in strings.split_lines_iterator(&rest) { 70 number += 1 71 rule, why, found := finding.dismissal(line) 72 if !found || !matches(rule_id, rule) { 73 continue 74 } 75 // A dismissal quoted inside a string literal, as a test's 76 // fixture is, ends where the literal's line does. 77 if cut := strings.index(why, "\\n"); cut >= 0 { 78 why = why[:cut] 79 } 80 why = strings.trim_space(strings.trim_suffix(strings.trim_space(why), "\"")) 81 if why == "" { 82 why = "no reason given" 83 } 84 spots := by_rule[rule] 85 if spots.allocator.procedure == nil { 86 spots = make([dynamic]Spot, context.temp_allocator) 87 } 88 append(&spots, Spot{fmt.tprintf("%s:%d", file, number), why}) 89 by_rule[strings.clone(rule, context.temp_allocator)] = spots 90 } 91 } 92 b := strings.builder_make(allocator) 93 if len(by_rule) == 0 { 94 strings.write_string(&b, "no dismissals in the tree\n") 95 return strings.to_string(b) 96 } 97 rules := sorted_keys(by_rule) 98 // The most dismissed first, the rest by name. 99 for i in 1 ..< len(rules) { 100 for j := i; j > 0 && len(by_rule[rules[j]]) > len(by_rule[rules[j - 1]]); j -= 1 { 101 rules[j], rules[j - 1] = rules[j - 1], rules[j] 102 } 103 } 104 for rule in rules { 105 spots := by_rule[rule] 106 fmt.sbprintf(&b, "%-28s %d\n", rule, len(spots)) 107 for spot in spots { 108 fmt.sbprintf(&b, " %s %s\n", spot.where_at, spot.why) 109 } 110 } 111 return strings.to_string(b) 112 } 113 114 // The shapes a test's declaration takes on a diff line, per runner. 115 go_test :: `^func ((?:Test|Benchmark|Fuzz)[A-Za-z0-9_]+)\(` 116 py_test :: `^(?:async\s+)?def (test_\w+)\(` 117 js_test_call :: `^(?:Deno\.test|test|describe|it)(?:\.(?:skip|only|todo|fails|failing|ignore|concurrent|serial))*\(` 118 rs_test_attribute :: `^\#\[[\w:]*test(\(|\])` 119 rs_fn :: `^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn (\w+)\(` 120 121 // check_deleted_tests reports the tests a change deletes. A test is never 122 // heard from again once deleted, so deletion is where a test the reading 123 // would have failed goes to pass the review. A test the change also adds 124 // under a name made of the same words is a rename, and is spared. 125 check_deleted_tests :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 126 spoken := added_test_names(s.c.added, context.temp_allocator) 127 for file in sorted_keys(s.c.removed) { 128 if !is_test_file(file) && !strings.has_suffix(file, ".rs") { 129 continue 130 } 131 removed := s.c.removed[file] 132 names := make([dynamic]string, context.temp_allocator) 133 seen := make(map[string]bool, context.temp_allocator) 134 for text, i in removed { 135 name := removed_test_name(text) 136 if strings.has_suffix(file, ".rs") { 137 // A Rust test is the function after the attribute, and 138 // only that; a deleted function without one is not a test. 139 name = "" 140 if matches(rs_test_attribute, strings.trim_space(text)) && i + 1 < len(removed) { 141 if m, ok := capture(rs_fn, strings.trim_space(removed[i + 1])); ok { 142 name = m[1] 143 } 144 } 145 } 146 if name == "" || seen[name] { 147 continue 148 } 149 seen[name] = true 150 if !covered(name, spoken) { 151 append(&names, name) 152 } 153 } 154 if len(names) == 0 { 155 continue 156 } 157 shown := make([dynamic]string, context.temp_allocator) 158 for name, i in names { 159 if i == 8 { 160 append(&shown, fmt.tprintf("and %d more", len(names) - 8)) 161 break 162 } 163 append(&shown, fmt.tprintf("%q", name)) 164 } 165 append( 166 out, 167 static( 168 "test-deleted", 169 .Must_Fix, 170 fmt.aprintf( 171 "the change deletes the test%s %s from %s; a deleted test cannot fail again, so deletion is where a failing test goes to pass the review", 172 plural(len(names)), 173 strings.join(shown[:], ", ", context.temp_allocator), 174 file, 175 ), 176 fmt.aprintf( 177 "restore the test, or dismiss it where a reader can read why: a review:ignore comment naming test-deleted, with the reason, in %s", 178 file, 179 ), 180 file = file, 181 symbol = strings.clone(names[0]), 182 ), 183 ) 184 } 185 } 186 187 // removed_test_name reads the name of a deleted test out of its removed 188 // line, in the shapes the tool's languages write tests in. 189 removed_test_name :: proc(text: string) -> string { 190 trimmed := strings.trim_left(text, " \t") 191 if m, ok := capture(go_test, trimmed); ok { 192 return m[1] 193 } 194 if m, ok := capture(py_test, trimmed); ok { 195 return m[1] 196 } 197 if end, ok := capture_end(js_test_call, trimmed); ok { 198 return read_quoted(strings.trim_left(trimmed[end:], " \t")) 199 } 200 return "" 201 } 202 203 // read_quoted reads the string literal a JS test registration is named 204 // by, from the text that follows its opening parenthesis. 205 read_quoted :: proc(text: string) -> string { 206 if len(text) == 0 { 207 return "" 208 } 209 q := text[0] 210 if q != '"' && q != '\'' && q != '`' { 211 return "" 212 } 213 if end := strings.index_byte(text[1:], q); end >= 0 { 214 return text[1:1 + end] 215 } 216 return "" 217 } 218 219 // added_test_names collects the names of the tests a change adds, read 220 // with the same shapes the removed side is read with, so that a rename is 221 // not read as a deletion. 222 added_test_names :: proc( 223 added: map[string][dynamic]change.Diff_Line, 224 allocator := context.allocator, 225 ) -> []string { 226 seen := make(map[string]bool, context.temp_allocator) 227 for _, lines in added { 228 for l in lines { 229 if name := added_test_name(strings.trim_left(l.text, " \t")); name != "" { 230 seen[name] = true 231 } 232 } 233 } 234 return sorted_keys(seen, allocator) 235 } 236 237 // added_test_name reads a test's name off an added line, in any runner's 238 // shape, or nothing. 239 added_test_name :: proc(trimmed: string) -> string { 240 for pattern in ([]string{go_test, py_test, rs_fn}) { 241 if m, ok := capture(pattern, trimmed); ok { 242 return m[1] 243 } 244 } 245 if end, ok := capture_end(js_test_call, trimmed); ok { 246 return read_quoted(strings.trim_left(trimmed[end:], " \t")) 247 } 248 return "" 249 } 250 251 // covered reports whether a deleted test's name is spoken for by a test 252 // the change adds: every word of the old name is in a new one, which is 253 // what a rename or a re-anchored test looks like. 254 covered :: proc(deleted: string, spoken: []string) -> bool { 255 want := test_words(deleted, context.temp_allocator) 256 if len(want) == 0 { 257 return false 258 } 259 for name in spoken { 260 have := test_words(name, context.temp_allocator) 261 says_all := true 262 for w in want { 263 found := false 264 for h in have { 265 if h == w { 266 found = true 267 break 268 } 269 } 270 if !found { 271 says_all = false 272 break 273 } 274 } 275 if says_all { 276 return true 277 } 278 } 279 return false 280 } 281 282 // test_words breaks a test's name into its lowercased words: camel humps 283 // and underscores for the Go shapes, spaces for the strings the 284 // JavaScript shapes are named by, with the runner's own prefixes and 285 // short leftovers left out. 286 test_words :: proc(name: string, allocator := context.allocator) -> []string { 287 out := make([dynamic]string, allocator) 288 for part in txt.split_words(name, context.temp_allocator) { 289 for piece in strings.fields(part, context.temp_allocator) { 290 word := strings.to_lower(piece, allocator) 291 if len(word) < 3 { 292 continue 293 } 294 switch word { 295 case "test", "benchmark", "fuzz", "skip", "only", "todo", "fails": 296 continue 297 } 298 append(&out, word) 299 } 300 } 301 return out[:] 302 }