analyser.odin (9835B)
1 /* 2 Package analyser runs the compilers and analysers a repository's languages 3 already have, which are the deterministic checks with the most to say. 4 What review decides is which of their findings belong to the change: an 5 error anywhere in a unit the change touched is the change's to answer, 6 because the tree does not compile until it is; a warning is the change's 7 only where it lands on a line the change added. Each is run with its 8 strictest settings and asked for JSON, on the tree the change arrives at. 9 */ 10 package analyser 11 12 import "core:fmt" 13 import "core:os" 14 import "core:path/filepath" 15 import "core:strconv" 16 import "core:strings" 17 import "core:text/regex" 18 import "core:time" 19 import "jm:sh" 20 21 import "../change" 22 import "../finding" 23 import "../tree" 24 25 // timeout_seconds bounds one analyser's run. A cold first analysis of a 26 // large module can take minutes; past this the analyser says nothing 27 // rather than holding the review. 28 timeout_seconds :: 300 29 30 // timeout_seconds_override is the bound a test lowers; zero means the 31 // constant above. 32 timeout_seconds_override: int 33 34 // Diagnostic is one thing an analyser said, located in the tree. A fault 35 // is a compile error — the tree does not build — rather than an 36 // analyser's opinion about code that does. 37 Diagnostic :: struct { 38 file: string, 39 line: int, 40 code: string, 41 message: string, 42 severity: finding.Severity, 43 fault: bool, 44 } 45 46 // Analyser is one compiler or analyser, run over the units the change's 47 // files belong to. name prefixes its rule ids: name/code. covers is 48 // whether a path is one of its language. ready is whether it can run in 49 // this tree, with the reason it cannot, told once. run analyses the units 50 // the files belong to and returns what it found with paths relative to 51 // the tree, or why it could not. in_place is whether it needs the working 52 // tree's surroundings — installed packages, a node_modules — and so cannot 53 // read a range's materialised tree. 54 Analyser :: struct { 55 name: string, 56 covers: proc(path: string) -> bool, 57 ready: proc(tree: string) -> (ok: bool, why: string), 58 run: proc(tree, root: string, files: []string) -> (found: []Diagnostic, err: string), 59 in_place: bool, 60 } 61 62 // all is the compilers and analysers review knows how to run, in the 63 // order their findings are worth having. 64 all :: proc(allocator := context.temp_allocator) -> []Analyser { 65 list := make([]Analyser, 9, allocator) 66 list[0], list[1], list[2] = go_build, go_vet, staticcheck 67 list[3], list[4], list[5] = odin_check, tsc, ruff 68 list[6], list[7], list[8] = mypy, cargo, semgrep 69 return list 70 } 71 72 // check runs every analyser that covers a changed file and can run, and 73 // keeps the findings that belong to the change. What could not run is 74 // said on stderr. 75 check :: proc( 76 c: ^change.Change, 77 t: tree.Tree, 78 ranged: bool, 79 analysers := []Analyser{}, 80 allocator := context.allocator, 81 ) -> []finding.Finding { 82 context.allocator = allocator 83 out := make([dynamic]finding.Finding) 84 changed := make(map[string]bool, context.temp_allocator) 85 for f in c.files { 86 changed[f] = true 87 } 88 which := analysers if len(analysers) > 0 else all() 89 for a in which { 90 files := make([dynamic]string, context.temp_allocator) 91 for f in c.files { 92 if a.covers(f) && tree.exists(t, f) { 93 append(&files, f) 94 } 95 } 96 if len(files) == 0 { 97 continue 98 } 99 if ok, why := a.ready(t.dir); !ok { 100 if why != "" { 101 fmt.eprintfln("skipping %s: %s", a.name, why) 102 } 103 continue 104 } 105 if a.in_place && ranged { 106 fmt.eprintfln( 107 "skipping %s: it reads the working tree, and the change is a range", 108 a.name, 109 ) 110 continue 111 } 112 found, err := a.run(t.dir, t.root, files[:]) 113 if err != "" { 114 fmt.eprintfln("skipping %s: %s", a.name, err) 115 continue 116 } 117 seen := make(map[string]bool, context.temp_allocator) 118 for d in found { 119 if d.file == "" || d.line == 0 { 120 continue 121 } 122 // A fault is the change's wherever it lands: the unit it 123 // touched no longer compiles. An opinion is the change's only 124 // on a line it added. 125 if !d.fault && (!changed[d.file] || !added_line(c, d.file, d.line)) { 126 continue 127 } 128 key := fmt.tprintf("%s:%d:%s:%s", d.file, d.line, d.code, d.message) 129 if seen[key] { 130 continue 131 } 132 seen[strings.clone(key, context.temp_allocator)] = true 133 rule := a.name if d.code == "" else fmt.aprintf("%s/%s", a.name, d.code) 134 append( 135 &out, 136 finding.Finding { 137 job = "static", 138 rule = rule, 139 severity = d.severity, 140 file = strings.clone(d.file), 141 line = d.line, 142 message = strings.clone(d.message), 143 verified = true, 144 }, 145 ) 146 } 147 } 148 return out[:] 149 } 150 151 // added_line is whether the diff added the line of the file. 152 added_line :: proc(c: ^change.Change, file: string, line: int) -> bool { 153 lines := c.added[file] 154 for l in lines { 155 if l.line == line { 156 return true 157 } 158 } 159 return false 160 } 161 162 // execute works one command in the tree and returns its output. A command 163 // that reports findings by exiting non-zero is not a failed run: its 164 // stdout is the answer, and only an empty stdout with a non-zero exit is a 165 // fault. A run past the timeout is a fault of its own. 166 execute :: proc( 167 dir: string, 168 name: string, 169 args: []string, 170 allocator := context.allocator, 171 ) -> ( 172 stdout: string, 173 err: string, 174 ) { 175 out, errs, e := execute_both(dir, name, args, allocator) 176 if e != "" && len(out) == 0 { 177 return "", e if errs == "" else e 178 } 179 return out, "" 180 } 181 182 // execute_both works one command and returns both streams: the Odin 183 // compiler writes its JSON to stderr, and a tool that fails before it 184 // starts says why there too. 185 execute_both :: proc( 186 dir: string, 187 name: string, 188 args: []string, 189 allocator := context.allocator, 190 ) -> ( 191 stdout, stderr: string, 192 err: string, 193 ) { 194 argv := make([dynamic]string, context.temp_allocator) 195 append(&argv, name) 196 append(&argv, ..args) 197 bound := timeout_seconds if timeout_seconds_override == 0 else timeout_seconds_override 198 r := sh.exec(argv[:], {dir = dir, timeout = time.Duration(bound) * time.Second}, allocator) 199 if r.err != nil { 200 return "", "", fmt.aprintf("%s: %s", name, os.error_string(r.err), allocator = allocator) 201 } 202 if r.timed_out { 203 return r.stdout, r.stderr, fmt.aprintf("a run past %d seconds is not waited for", bound, allocator = allocator) 204 } 205 if !r.ok { 206 detail := strings.trim_space(r.stderr) 207 if detail == "" { 208 detail = fmt.tprintf("exit %d", r.code) 209 } 210 return r.stdout, r.stderr, fmt.aprintf("%s: %s", name, tail(detail, 200), allocator = allocator) 211 } 212 return r.stdout, r.stderr, "" 213 } 214 215 // on_path is a ready that needs only the binary. 216 on_path :: proc(binary: string) -> bool { 217 _, found := sh.which(binary, context.temp_allocator) 218 return found 219 } 220 221 // relative is a path relative to the tree, where it lies inside it. 222 relative :: proc(tree_dir, path: string) -> string { 223 if strings.has_prefix(path, tree_dir) && 224 len(path) > len(tree_dir) && 225 path[len(tree_dir)] == '/' { 226 return path[len(tree_dir) + 1:] 227 } 228 if strings.has_prefix(path, "./") { 229 return path[2:] 230 } 231 return path 232 } 233 234 // join is a path under a directory. 235 join :: proc(dir, name: string, allocator := context.temp_allocator) -> string { 236 return filepath.join({dir, name}, allocator) or_else name 237 } 238 239 // dir_of is the directory a path sits in, "." at the top. 240 dir_of :: proc(path: string) -> string { 241 if i := strings.last_index_byte(path, '/'); i >= 0 { 242 return path[:i] 243 } 244 return "." 245 } 246 247 // nearest is the path, relative to the tree, of the first file called 248 // name in dir or a directory above it, or empty. 249 nearest :: proc(tree_dir, dir, name: string) -> string { 250 d := dir 251 for { 252 candidate := 253 name if d == "." || d == "" else strings.concatenate({d, "/", name}, context.temp_allocator) 254 if os.is_file(join(tree_dir, candidate)) { 255 return candidate 256 } 257 if d == "." || d == "" || d == "/" { 258 return "" 259 } 260 d = dir_of(d) 261 } 262 } 263 264 // tail is the last few bytes of a longer text, for an error message. 265 tail :: proc(s: string, n: int) -> string { 266 trimmed := strings.trim_space(s) 267 if len(trimmed) <= n { 268 return trimmed 269 } 270 return strings.concatenate({"…", trimmed[len(trimmed) - n:]}, context.temp_allocator) 271 } 272 273 // position reads file and line out of file:line:col: message, as the Go 274 // tools print it. 275 position :: proc(text: string) -> (file: string, line: int, message: string, ok: bool) { 276 re, err := regex.create( 277 `^(.+?):(\d+)(?::(\d+))?: (.*)$`, 278 {}, 279 context.temp_allocator, 280 context.temp_allocator, 281 ) 282 if err != nil { 283 return 284 } 285 cap, matched := regex.match(re, text, context.temp_allocator) 286 if !matched { 287 return 288 } 289 line, _ = strconv.parse_int(cap.groups[2]) 290 return cap.groups[1], line, cap.groups[4], true 291 } 292 293 // split_json_objects cuts a stream of top-level JSON objects, with 294 // anything between them — vet's # comment lines, prose about a failed 295 // package — left out. An object opens only at the start of a line, as vet 296 // writes them, so a brace in the prose opens nothing. 297 split_json_objects :: proc(out: string, allocator := context.allocator) -> []string { 298 chunks := make([dynamic]string, allocator) 299 depth, start := 0, -1 300 in_string, line_start := false, true 301 i := 0 302 for i < len(out) { 303 c := out[i] 304 switch { 305 case in_string: 306 if c == '\\' { 307 i += 1 308 } else if c == '"' { 309 in_string = false 310 } 311 case depth > 0 && c == '"': 312 in_string = true 313 case c == '{' && (depth > 0 || line_start): 314 if depth == 0 { 315 start = i 316 } 317 depth += 1 318 case c == '}' && depth > 0: 319 depth -= 1 320 if depth == 0 { 321 append(&chunks, out[start:i + 1]) 322 start = -1 323 } 324 } 325 line_start = c == '\n' 326 i += 1 327 } 328 return chunks[:] 329 } 330 331 // lines_of is a text's lines, for the tools that write one JSON object 332 // per line. 333 lines_of :: proc(text: string, allocator := context.temp_allocator) -> []string { 334 return strings.split_lines(text, allocator) 335 }