review

review patchsets using your default editor
Log | Files | Refs

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 }