coverage.odin (6506B)
1 package check 2 3 // Two things about a change are visible only against the whole 4 // repository: whether anything refers to what it adds, and whether it 5 // added tests where the repository keeps them. Both are counted over the 6 // tree in memory. 7 8 import "base:runtime" 9 import "core:fmt" 10 import "core:strings" 11 12 import "../change" 13 import "../finding" 14 15 @(private = "file") 16 unsearchable: map[string]bool 17 @(private = "file") 18 runtime_methods: map[string]bool 19 20 @(init) 21 init_coverage_lists :: proc "contextless" () { 22 context = runtime.default_context() 23 // The names the language calls rather than the code, and names too 24 // short to search for. 25 unsearchable = set(`main init TestMain _ default`) 26 // The methods the standard library and its encoders call through an 27 // interface or by reflection, so that nothing in the repository names 28 // them and they are referenced all the same. 29 runtime_methods = set( 30 `String Error Format GoString MarshalJSON UnmarshalJSON MarshalText UnmarshalText 31 MarshalBinary UnmarshalBinary MarshalYAML UnmarshalYAML GobEncode GobDecode Len Less Swap Read Write 32 Close Seek ReadFrom WriteTo ServeHTTP Scan Value Is As Unwrap Compare Equal Hash`, 33 ) 34 } 35 36 // check_unreferenced reports a new declaration nothing in the repository 37 // refers to: not the change, not the rest of the tree. It is written and 38 // waiting, and what waits drifts. The name is searched as a whole word 39 // over every text file, so a use from a template or a script counts. One 40 // finding per file, naming what it declares and nothing refers to. 41 check_unreferenced :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 42 unused := make(map[string][dynamic]change.Symbol, context.temp_allocator) 43 for sym in s.c.symbols { 44 if is_test_file(sym.file) || 45 sym.kind == "field" || 46 unsearchable[sym.name] || 47 called_by_the_runtime(sym) { 48 continue 49 } 50 if referenced(sym, s.sources) { 51 continue 52 } 53 list := unused[sym.file] 54 if list.allocator.procedure == nil { 55 list = make([dynamic]change.Symbol, context.temp_allocator) 56 } 57 append(&list, sym) 58 unused[sym.file] = list 59 } 60 for file in sorted_keys(unused) { 61 symbols := unused[file] 62 names := make([dynamic]string, context.temp_allocator) 63 for sym, i in symbols { 64 if i == 8 { 65 append(&names, fmt.tprintf("and %d more", len(symbols) - 8)) 66 break 67 } 68 append(&names, sym.name) 69 } 70 append( 71 out, 72 static( 73 "new-symbol-unreferenced", 74 .Consider, 75 fmt.aprintf( 76 "nothing in the repository refers to %s but the declaration%s in %s; code written for a caller that does not exist yet is a guess about what the caller will need", 77 strings.join(names[:], ", ", context.temp_allocator), 78 plural(len(symbols)), 79 file, 80 ), 81 "use it, or leave it out until something does", 82 file = file, 83 line = symbols[0].line, 84 symbol = symbols[0].name, 85 ), 86 ) 87 } 88 } 89 90 // called_by_the_runtime is whether a symbol is called by something 91 // outside the repository's text: a method an encoder or interface reaches 92 // for, or a function cgo exports to the host under //export. 93 called_by_the_runtime :: proc(sym: change.Symbol) -> bool { 94 if sym.kind == "func" && runtime_methods[sym.name] { 95 return true 96 } 97 if strings.has_suffix(sym.file, ".go") { 98 rest := sym.doc 99 for line in strings.split_lines_iterator(&rest) { 100 if strings.trim_space(line) == 101 strings.concatenate({"export ", sym.name}, context.temp_allocator) { 102 return true 103 } 104 } 105 } 106 return false 107 } 108 109 // referenced is whether the name appears as a whole word anywhere but on 110 // its own declaration line or in a comment. A Go doc comment opens with 111 // the name it documents, and a comment is not a caller. 112 referenced :: proc(sym: change.Symbol, sources: map[string][]byte) -> bool { 113 if len(sym.name) < 2 { 114 return true // Too short to search for honestly. 115 } 116 for file, data in sources { 117 text := string(data) 118 offset := 0 119 for { 120 i := strings.index(text[offset:], sym.name) 121 if i < 0 { 122 break 123 } 124 at := offset + i 125 offset = at + len(sym.name) 126 if !word_boundary(text, at, len(sym.name)) { 127 continue 128 } 129 if file == sym.file && line_of(text, at) == sym.line { 130 continue 131 } 132 if in_comment(text, at) { 133 continue 134 } 135 return true 136 } 137 } 138 return false 139 } 140 141 // word_boundary is whether the match at i of length n is bounded by 142 // non-identifier characters on both sides. 143 word_boundary :: proc(text: string, i, n: int) -> bool { 144 before := i == 0 || !ident_char(text[i - 1]) 145 after := i + n >= len(text) || !ident_char(text[i + n]) 146 return before && after 147 } 148 149 ident_char :: proc(b: byte) -> bool { 150 return b == '_' || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') 151 } 152 153 // in_comment is whether the offset sits on a line that is a comment by 154 // the shapes the tool's languages share, or after a line comment's 155 // opening. 156 in_comment :: proc(text: string, offset: int) -> bool { 157 start := strings.last_index_byte(text[:offset], '\n') + 1 158 line := text[start:offset] 159 head := strings.concatenate( 160 {line, text[offset:min(offset + 1, len(text))]}, 161 context.temp_allocator, 162 ) 163 if change.is_comment_line(strings.trim_space(head)) { 164 return true 165 } 166 return strings.contains(line, "//") || strings.contains(line, "/*") 167 } 168 169 // line_of is the 1-based line the offset falls on. 170 line_of :: proc(text: string, offset: int) -> int { 171 return 1 + strings.count(text[:offset], "\n") 172 } 173 174 // test_floor_lines is the size of change, in lines added to code that is 175 // not a test, from which a change owes a test where the repository keeps 176 // them. 177 test_floor_lines :: 50 178 179 // check_code_without_tests reports a change that adds a body of code to a 180 // repository that has tests, and touches none of them. 181 check_code_without_tests :: proc(s: Scope, out: ^[dynamic]finding.Finding) { 182 lines := 0 183 for file, l in s.c.added { 184 if change.is_code_file(file) && !is_test_file(file) { 185 lines += len(l) 186 } 187 } 188 if lines < test_floor_lines { 189 return 190 } 191 for f in s.c.files { 192 if is_test_file(f) { 193 return 194 } 195 } 196 tested := 0 197 for f in s.files { 198 if is_test_file(f) { 199 tested += 1 200 } 201 } 202 if tested == 0 { 203 return // A repository without tests is not asked to start here. 204 } 205 append( 206 out, 207 static( 208 "code-without-tests", 209 .Consider, 210 fmt.aprintf( 211 "the change adds %d lines of code and touches no test, in a repository that keeps %d test files; what the change does is asserted nowhere", 212 lines, 213 tested, 214 ), 215 "add or extend the test that would fail without this change", 216 ), 217 ) 218 }