review

review patchsets using your default editor
Log | Files | Refs

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 }