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 }