review

review patchsets using your default editor
Log | Files | Refs

others.odin (12744B)


      1 package analyser
      2 
      3 import "core:encoding/json"
      4 import "core:os"
      5 import "core:strconv"
      6 import "core:strings"
      7 import "core:text/regex"
      8 import "jm:sh"
      9 
     10 import "../finding"
     11 
     12 // grammar_of is whether a path is TypeScript or JavaScript.
     13 is_script :: proc(path: string) -> bool {
     14 	for ext in ([]string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}) {
     15 		if strings.has_suffix(path, ext) {
     16 			return true
     17 		}
     18 	}
     19 	return false
     20 }
     21 
     22 // tsc type-checks each project a changed TypeScript file belongs to,
     23 // under the project's own tsconfig — strictness is the project's to set —
     24 // and emits nothing. It needs the project's node_modules, so it reads the
     25 // working tree only.
     26 tsc := Analyser {
     27 	name = "tsc",
     28 	covers = is_script,
     29 	in_place = true,
     30 	ready = proc(tree_dir: string) -> (bool, string) {
     31 		return on_path("tsc") || os.is_file(join(tree_dir, "node_modules/.bin/tsc")), ""
     32 	},
     33 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
     34 		binary := join(tree_dir, "node_modules/.bin/tsc")
     35 		if !os.is_file(binary) {
     36 			binary = "tsc"
     37 		}
     38 		projects := make(map[string]bool, context.temp_allocator)
     39 		for f in files {
     40 			if p := nearest(tree_dir, dir_of(f), "tsconfig.json"); p != "" {
     41 				projects[p] = true
     42 			}
     43 		}
     44 		if len(projects) == 0 {
     45 			return nil, "no tsconfig.json above the changed files"
     46 		}
     47 		found := make([dynamic]Diagnostic)
     48 		for project in sorted(projects) {
     49 			dir := join(tree_dir, dir_of(project))
     50 			out, err := execute(
     51 				dir,
     52 				binary,
     53 				{"--noEmit", "--pretty", "false", "-p", "tsconfig.json"},
     54 				context.temp_allocator,
     55 			)
     56 			if err != "" {
     57 				return found[:], err
     58 			}
     59 			append(&found, ..parse_tsc(tree_dir, dir, out, context.temp_allocator))
     60 		}
     61 		return found[:], ""
     62 	},
     63 }
     64 
     65 // parse_tsc reads the compiler's plain output — file(line,col): error
     66 // TSnnnn: message — with paths relative to the project directory it ran
     67 // in.
     68 parse_tsc :: proc(tree_dir, dir, out: string, allocator := context.allocator) -> []Diagnostic {
     69 	found := make([dynamic]Diagnostic, allocator)
     70 	re, err := regex.create(
     71 		`^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$`,
     72 		{},
     73 		context.temp_allocator,
     74 		context.temp_allocator,
     75 	)
     76 	if err != nil {
     77 		return found[:]
     78 	}
     79 	for line in lines_of(out) {
     80 		cap, ok := regex.match(re, strings.trim_space(line), context.temp_allocator)
     81 		if !ok {
     82 			continue
     83 		}
     84 		number, _ := strconv.parse_int(cap.groups[2])
     85 		append(
     86 			&found,
     87 			Diagnostic {
     88 				file = strings.clone(relative(tree_dir, join(dir, cap.groups[1])), allocator),
     89 				line = number,
     90 				code = strings.clone(cap.groups[4], allocator),
     91 				message = strings.clone(cap.groups[5], allocator),
     92 				severity = .Must_Fix,
     93 				fault = true,
     94 			},
     95 		)
     96 	}
     97 	return found[:]
     98 }
     99 
    100 is_python :: proc(path: string) -> bool {
    101 	return strings.has_suffix(path, ".py")
    102 }
    103 
    104 // ruff lints the changed Python files with the project's own
    105 // configuration and reads its JSON. Its style codes are notes; what
    106 // pyflakes would have said is worth considering.
    107 ruff := Analyser {
    108 	name = "ruff",
    109 	covers = is_python,
    110 	ready = proc(tree_dir: string) -> (bool, string) {return on_path("ruff"), ""},
    111 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    112 		args := make([dynamic]string, context.temp_allocator)
    113 		append(&args, "check", "--output-format", "json", "--exit-zero")
    114 		append(&args, ..files)
    115 		out, err := execute(tree_dir, "ruff", args[:], context.temp_allocator)
    116 		if err != "" {
    117 			return nil, err
    118 		}
    119 		return parse_ruff(tree_dir, out)
    120 	},
    121 }
    122 
    123 // Ruff_Finding is one entry of ruff's JSON array.
    124 Ruff_Finding :: struct {
    125 	code:     string `json:"code"`,
    126 	message:  string `json:"message"`,
    127 	filename: string `json:"filename"`,
    128 	location: struct {
    129 		row: int `json:"row"`,
    130 	} `json:"location"`,
    131 }
    132 
    133 // parse_ruff reads ruff's JSON array. Style codes — E, W, import order,
    134 // docstrings — are notes; the rest is worth considering.
    135 parse_ruff :: proc(
    136 	tree_dir, out: string,
    137 	allocator := context.allocator,
    138 ) -> (
    139 	[]Diagnostic,
    140 	string,
    141 ) {
    142 	report: []Ruff_Finding
    143 	if json.unmarshal_string(out, &report, allocator = context.temp_allocator) != nil {
    144 		return nil, "ruff: its answer is not JSON"
    145 	}
    146 	found := make([dynamic]Diagnostic, allocator)
    147 	for r in report {
    148 		severity := finding.Severity.Consider
    149 		if strings.has_prefix(r.code, "E") ||
    150 		   strings.has_prefix(r.code, "W") ||
    151 		   strings.has_prefix(r.code, "I") ||
    152 		   strings.has_prefix(r.code, "D") {
    153 			severity = .Note
    154 		}
    155 		append(
    156 			&found,
    157 			Diagnostic {
    158 				file = strings.clone(relative(tree_dir, r.filename), allocator),
    159 				line = r.location.row,
    160 				code = strings.clone(r.code, allocator),
    161 				message = strings.clone(r.message, allocator),
    162 				severity = severity,
    163 			},
    164 		)
    165 	}
    166 	return found[:], ""
    167 }
    168 
    169 // mypy type-checks the changed Python files and reads its JSON, one
    170 // object per line. Imports it cannot find are the environment's business,
    171 // not the change's, and are not reported.
    172 mypy := Analyser {
    173 	name = "mypy",
    174 	covers = is_python,
    175 	in_place = true,
    176 	ready = proc(tree_dir: string) -> (bool, string) {return on_path("mypy"), ""},
    177 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    178 		args := make([dynamic]string, context.temp_allocator)
    179 		append(&args, "--output", "json", "--no-error-summary", "--ignore-missing-imports")
    180 		append(&args, ..files)
    181 		out, err := execute(tree_dir, "mypy", args[:], context.temp_allocator)
    182 		if err != "" {
    183 			return nil, err
    184 		}
    185 		return parse_mypy(tree_dir, out), ""
    186 	},
    187 }
    188 
    189 // Mypy_Finding is one line of mypy's JSON.
    190 Mypy_Finding :: struct {
    191 	file:     string `json:"file"`,
    192 	line:     int `json:"line"`,
    193 	message:  string `json:"message"`,
    194 	code:     string `json:"code"`,
    195 	severity: string `json:"severity"`,
    196 }
    197 
    198 // parse_mypy reads mypy's JSON, one object per line: an error is a type
    199 // error and must-fix, anything else a note.
    200 parse_mypy :: proc(tree_dir, out: string, allocator := context.allocator) -> []Diagnostic {
    201 	found := make([dynamic]Diagnostic, allocator)
    202 	for line in lines_of(out) {
    203 		r: Mypy_Finding
    204 		if json.unmarshal_string(line, &r, allocator = context.temp_allocator) != nil ||
    205 		   r.file == "" {
    206 			continue
    207 		}
    208 		append(
    209 			&found,
    210 			Diagnostic {
    211 				file = strings.clone(relative(tree_dir, r.file), allocator),
    212 				line = r.line,
    213 				code = strings.clone(r.code, allocator),
    214 				message = strings.clone(r.message, allocator),
    215 				severity = .Must_Fix if r.severity == "error" else .Note,
    216 			},
    217 		)
    218 	}
    219 	return found[:]
    220 }
    221 
    222 // cargo type-checks each crate a changed Rust file belongs to — with
    223 // clippy where it is installed, which checks and lints in one run — and
    224 // reads the compiler's JSON messages. The target directory is the
    225 // repository's own, so a range's materialised tree reuses the build
    226 // cache.
    227 cargo := Analyser {
    228 	name = "cargo",
    229 	covers = proc(path: string) -> bool {return strings.has_suffix(path, ".rs")},
    230 	ready = proc(tree_dir: string) -> (bool, string) {return on_path("cargo"), ""},
    231 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    232 		crates := make(map[string]bool, context.temp_allocator)
    233 		for f in files {
    234 			if c := nearest(tree_dir, dir_of(f), "Cargo.toml"); c != "" {
    235 				crates[c] = true
    236 			}
    237 		}
    238 		if len(crates) == 0 {
    239 			return nil, "no Cargo.toml above the changed files"
    240 		}
    241 		verb :=
    242 			"clippy" if sh.exec({"cargo", "clippy", "--version"}, allocator = context.temp_allocator).ok else "check"
    243 		found := make([dynamic]Diagnostic)
    244 		for crate in sorted(crates) {
    245 			dir := join(tree_dir, dir_of(crate))
    246 			target := join(root, join(dir_of(crate), "target"))
    247 			os.set_env("CARGO_TARGET_DIR", target)
    248 			out, err := execute(
    249 				dir,
    250 				"cargo",
    251 				{verb, "--message-format", "json", "--quiet"},
    252 				context.temp_allocator,
    253 			)
    254 			os.unset_env("CARGO_TARGET_DIR")
    255 			if err != "" {
    256 				return found[:], err
    257 			}
    258 			append(&found, ..parse_cargo(tree_dir, dir, out, context.temp_allocator))
    259 		}
    260 		return found[:], ""
    261 	},
    262 }
    263 
    264 // Cargo_Event is one line of cargo's JSON stream.
    265 Cargo_Event :: struct {
    266 	reason:  string `json:"reason"`,
    267 	message: struct {
    268 		level:   string `json:"level"`,
    269 		message: string `json:"message"`,
    270 		code:    Maybe(struct {
    271 				code: string `json:"code"`,
    272 			}) `json:"code"`,
    273 		spans:   []struct {
    274 			file:    string `json:"file_name"`,
    275 			line:    int `json:"line_start"`,
    276 			primary: bool `json:"is_primary"`,
    277 		} `json:"spans"`,
    278 	} `json:"message"`,
    279 }
    280 
    281 // parse_cargo reads the compiler messages out of cargo's JSON stream, one
    282 // per primary span, with paths relative to the crate directory it ran in.
    283 parse_cargo :: proc(tree_dir, dir, out: string, allocator := context.allocator) -> []Diagnostic {
    284 	found := make([dynamic]Diagnostic, allocator)
    285 	for line in lines_of(out) {
    286 		event: Cargo_Event
    287 		if json.unmarshal_string(line, &event, allocator = context.temp_allocator) != nil ||
    288 		   event.reason != "compiler-message" {
    289 			continue
    290 		}
    291 		for span in event.message.spans {
    292 			if !span.primary {
    293 				continue
    294 			}
    295 			d := Diagnostic {
    296 				file    = strings.clone(relative(tree_dir, join(dir, span.file)), allocator),
    297 				line    = span.line,
    298 				message = strings.clone(event.message.message, allocator),
    299 			}
    300 			if code, given := event.message.code.?; given {
    301 				d.code = strings.clone(code.code, allocator)
    302 			}
    303 			switch event.message.level {
    304 			case "error":
    305 				d.severity, d.fault = .Must_Fix, true
    306 			case "warning":
    307 				d.severity = .Consider
    308 			case:
    309 				continue
    310 			}
    311 			append(&found, d)
    312 			break
    313 		}
    314 	}
    315 	return found[:]
    316 }
    317 
    318 // semgrep_languages are the extensions semgrep parses: its rules are
    319 // about patterns, not types, and say things a compiler does not.
    320 semgrep_languages :: `.go .ts .tsx .js .jsx .mjs .cjs .py .rs .java .kt .kts .rb .php .c .h .cc .cpp .hpp .cs
    321 	.swift .scala .lua .ex .exs .dart .sh .bash .tf .yaml .yml .json .html .sol`
    322 
    323 // semgrep_config is the rule set semgrep is pointed at: the repository's
    324 // own configuration where it has one — that is the repository's word on
    325 // what matters — and the registry's default pack otherwise, fetched over
    326 // the network. Not auto: semgrep refuses to build that selection with
    327 // metrics off, and review never sends metrics.
    328 semgrep_config :: proc(tree_dir: string) -> string {
    329 	for name in ([]string{".semgrep.yml", ".semgrep.yaml", ".semgrep"}) {
    330 		if os.exists(join(tree_dir, name)) {
    331 			return name
    332 		}
    333 	}
    334 	return "p/default"
    335 }
    336 
    337 // semgrep runs the pattern analyser over the changed files it can parse,
    338 // and reads its JSON. A rule's severity is the rule author's word on it,
    339 // kept as reported: ERROR must-fix, WARNING consider, INFO note.
    340 semgrep := Analyser {
    341 	name = "semgrep",
    342 	covers = proc(path: string) -> bool {
    343 		for ext in strings.fields(semgrep_languages, context.temp_allocator) {
    344 			if strings.has_suffix(path, ext) {
    345 				return true
    346 			}
    347 		}
    348 		return false
    349 	},
    350 	ready = proc(tree_dir: string) -> (bool, string) {return on_path("semgrep"), ""},
    351 	run = proc(tree_dir, root: string, files: []string) -> ([]Diagnostic, string) {
    352 		args := make([dynamic]string, context.temp_allocator)
    353 		append(
    354 			&args,
    355 			"scan",
    356 			"--json",
    357 			"--quiet",
    358 			"--config",
    359 			semgrep_config(tree_dir),
    360 			"--metrics",
    361 			"off",
    362 		)
    363 		append(&args, ..files)
    364 		out, err := execute(tree_dir, "semgrep", args[:], context.temp_allocator)
    365 		if err != "" {
    366 			return nil, err
    367 		}
    368 		return parse_semgrep(tree_dir, out)
    369 	},
    370 }
    371 
    372 // Semgrep_Report is semgrep's JSON report: one result per match.
    373 Semgrep_Report :: struct {
    374 	results: []struct {
    375 		check_id: string `json:"check_id"`,
    376 		path:     string `json:"path"`,
    377 		start:    struct {
    378 			line: int `json:"line"`,
    379 		} `json:"start"`,
    380 		extra:    struct {
    381 			message:  string `json:"message"`,
    382 			severity: string `json:"severity"`,
    383 		} `json:"extra"`,
    384 	} `json:"results"`,
    385 }
    386 
    387 // parse_semgrep reads semgrep's JSON report, located by each match's
    388 // start line, with the rule's id and severity.
    389 parse_semgrep :: proc(
    390 	tree_dir, out: string,
    391 	allocator := context.allocator,
    392 ) -> (
    393 	[]Diagnostic,
    394 	string,
    395 ) {
    396 	report: Semgrep_Report
    397 	if json.unmarshal_string(out, &report, allocator = context.temp_allocator) != nil {
    398 		return nil, "semgrep: its answer is not JSON"
    399 	}
    400 	found := make([dynamic]Diagnostic, allocator)
    401 	for r in report.results {
    402 		severity := finding.Severity.Note
    403 		switch strings.to_upper(r.extra.severity, context.temp_allocator) {
    404 		case "ERROR", "HIGH", "CRITICAL":
    405 			severity = .Must_Fix
    406 		case "WARNING", "MEDIUM":
    407 			severity = .Consider
    408 		}
    409 		append(
    410 			&found,
    411 			Diagnostic {
    412 				file = strings.clone(relative(tree_dir, r.path), allocator),
    413 				line = r.start.line,
    414 				code = strings.clone(r.check_id, allocator),
    415 				message = strings.clone(strings.trim_space(r.extra.message), allocator),
    416 				severity = severity,
    417 			},
    418 		)
    419 	}
    420 	return found[:], ""
    421 }