review

review patchsets using your default editor
Log | Files | Refs

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 }