leftovers.odin (7147B)
1 package check 2 3 // What a change leaves behind by accident has a shape: a debugger 4 // statement, a task marker nobody is named on, code kept as a comment, an 5 // error caught and dropped. Each is a pattern over the lines the change 6 // adds. 7 8 import "core:fmt" 9 import "core:slice" 10 import "core:strings" 11 12 import "../change" 13 import "../finding" 14 15 // Debug_Marker is a call or statement that exists to be removed before a 16 // change is done, by language. Ordinary printing is not here: a command's 17 // output and a debug print share a function. 18 Debug_Marker :: struct { 19 suffixes: []string, 20 pattern: string, 21 severity: finding.Severity, 22 } 23 24 @(private = "file") 25 debug_markers := []Debug_Marker { 26 {{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}, `^\s*debugger\s*;?\s*$`, .Consider}, 27 {{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}, `\bconsole\.(log|debug|trace)\(`, .Note}, 28 {{".py"}, `\b(breakpoint\(\)|pdb\.set_trace\(\)|ipdb\.set_trace\(\))`, .Consider}, 29 {{".rs"}, `\bdbg!\(`, .Consider}, 30 {{".go"}, `\b(spew\.Dump|litter\.Dump|pp\.Print)\(`, .Consider}, 31 {{".rb"}, `\b(binding\.pry|byebug|debugger)\b`, .Consider}, 32 {nil, `\b(Printf|Println|Print|log|print|debug)\(\s*["'](DEBUG|XXX|HERE|>>>)`, .Consider}, 33 } 34 35 // check_debug_leftovers reports the debugging a change adds and did not 36 // remove. 37 check_debug_leftovers :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 38 for file in sorted_keys(s.c.added) { 39 if !change.is_code_file(file) { 40 continue 41 } 42 lines := s.c.added[file] 43 for l in lines { 44 for m in debug_markers { 45 if m.suffixes != nil && !has_suffix(file, m.suffixes) { 46 continue 47 } 48 if !matches(m.pattern, l.text) { 49 continue 50 } 51 append( 52 out, 53 static( 54 "debug-leftover", 55 m.severity, 56 fmt.aprintf( 57 "the change adds debugging output: %s", 58 strings.trim_space(l.text), 59 ), 60 "remove it before the change is done", 61 file = file, 62 line = l.line, 63 ), 64 ) 65 break 66 } 67 } 68 } 69 } 70 71 // task_marker is a comment that names work left undone; task_reference 72 // what makes it answerable: an issue number, a ticket key, a link, or a 73 // name in parentheses. 74 task_marker :: `\b(TODO|FIXME|XXX|HACK)\b` 75 task_reference :: `\#\d+|\b[A-Z][A-Z0-9]+-\d+\b|https?://|\(\w+\)` 76 77 // check_todos reports a task marker the change adds with nothing to find 78 // it by again. Unreferenced, it is a promise the log will not keep. 79 check_todos :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 80 for comment in s.c.comments { 81 if !matches(task_marker, comment.text) || matches(task_reference, comment.text) { 82 continue 83 } 84 append( 85 out, 86 static( 87 "todo-without-reference", 88 .Note, 89 fmt.aprintf( 90 "the change adds a task marker nothing refers to: %q", 91 first_line(comment.text, context.temp_allocator), 92 ), 93 "name the issue or the person, or do the work now", 94 file = comment.file, 95 line = comment.line, 96 ), 97 ) 98 } 99 } 100 101 // strong_code is a comment line that is code beyond doubt: an assignment 102 // operator, a call closed and terminated, a closing brace terminated, an 103 // arrow function. weak_code is a line shaped like a statement, which two 104 // in a row make into commented-out code. 105 strong_code :: `:=|\);\s*$|\};\s*$|=>|^\s*\}\s*else\s*\{` 106 weak_code :: `^(if|for|while|return|func|fn|def|import|const|let|var|switch|case|else|try|catch|package|use|pub|proc|struct|type|class|await|export)\b.*[({=:;]\s*$|^[\w.]+\(.*\)\s*;?\s*$|[;{}]\s*$` 107 108 // code_like is whether a comment line reads as code, and how surely. 109 code_like :: proc(text: string) -> (strong, weak: bool) { 110 trimmed := strings.trim_space(text) 111 if trimmed == "" || directive(trimmed) { 112 return false, false 113 } 114 return matches(strong_code, trimmed), matches(weak_code, trimmed) 115 } 116 117 // check_commented_code reports code the change keeps as comments. A run 118 // of consecutive comment lines is one candidate; it is reported when two 119 // of its lines are shaped like statements, or one is code beyond doubt. 120 check_commented_code :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 121 by_file := make(map[string][dynamic]change.Located, context.temp_allocator) 122 for comment in s.c.comments { 123 list := by_file[comment.file] 124 if list.allocator.procedure == nil { 125 list = make([dynamic]change.Located, context.temp_allocator) 126 } 127 append(&list, comment) 128 by_file[comment.file] = list 129 } 130 for file in sorted_keys(by_file) { 131 comments := by_file[file][:] 132 slice.sort_by_cmp(comments, proc(a, b: change.Located) -> slice.Ordering { 133 return .Less if a.line < b.line else (.Greater if a.line > b.line else .Equal) 134 }) 135 i := 0 136 for i < len(comments) { 137 j := i 138 strong, weak := 0, 0 139 for j < len(comments) && (j == i || comments[j].line == comments[j - 1].line + 1) { 140 st, wk := code_like(comments[j].text) 141 if st { 142 strong += 1 143 } 144 if wk || st { 145 weak += 1 146 } 147 j += 1 148 } 149 if strong > 0 || weak >= 2 { 150 append( 151 out, 152 static( 153 "commented-out-code", 154 .Consider, 155 fmt.aprintf( 156 "the change adds code as a comment, %d line(s) from %s:%d; the version control has the old code, and a reader cannot tell a comment that was code from one that is meant", 157 j - i, 158 file, 159 comments[i].line, 160 ), 161 "delete it; git remembers it", 162 file = file, 163 line = comments[i].line, 164 ), 165 ) 166 } 167 i = j 168 } 169 } 170 } 171 172 go_dropped_error :: `^\s*_\s*=\s*err\b` 173 empty_catch :: `\bcatch\s*(\([^)]*\))?\s*\{\s*\}` 174 open_catch :: `\bcatch\s*(\([^)]*\))?\s*\{\s*$` 175 promise_catch :: `\.catch\(\s*(\(\s*\w*\s*\)|\w+)?\s*=>\s*\{\s*\}\s*\)` 176 except_pass :: `^\s*except\b[^:]*:\s*pass\s*$` 177 except_open :: `^\s*except\b[^:]*:\s*$` 178 closing_brace :: `^\s*\}` 179 pass_line :: `^\s*pass\s*$` 180 181 // check_swallowed_errors reports an error the change catches and drops: 182 // a Go error assigned to the blank identifier, an empty catch in 183 // JavaScript or TypeScript, an except that passes in Python. 184 check_swallowed_errors :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 185 for file in sorted_keys(s.c.added) { 186 if !change.is_code_file(file) { 187 continue 188 } 189 lines := s.c.added[file] 190 for l, i in lines { 191 next := "" 192 if i + 1 < len(lines) && lines[i + 1].line == l.line + 1 { 193 next = lines[i + 1].text 194 } 195 hit := false 196 switch { 197 case strings.has_suffix(file, ".go"): 198 hit = matches(go_dropped_error, l.text) 199 case grammar_of(file) != "": 200 hit = 201 matches(empty_catch, l.text) || 202 matches(promise_catch, l.text) || 203 (matches(open_catch, l.text) && matches(closing_brace, next)) 204 case strings.has_suffix(file, ".py"): 205 hit = 206 matches(except_pass, l.text) || 207 (matches(except_open, l.text) && matches(pass_line, next)) 208 } 209 if !hit { 210 continue 211 } 212 append( 213 out, 214 static( 215 "error-swallowed", 216 .Consider, 217 fmt.aprintf( 218 "the change catches an error and drops it: %s; a dropped error is a failure the program has decided not to know about", 219 strings.trim_space(l.text), 220 ), 221 "handle it, return it, or write beside it why it cannot matter", 222 file = file, 223 line = l.line, 224 ), 225 ) 226 } 227 } 228 }