job.odin (15360B)
1 /* 2 Package job is the narrow readings a model is asked for. Each is given the 3 part of the change it needs and nothing else: a job that reads less is 4 cheaper, and harder to distract into reporting something another job 5 owns. The criteria a job judges against are the thing to tune when it 6 reports the wrong things, and they travel with the binary. 7 */ 8 package job 9 10 import "base:runtime" 11 import "core:encoding/json" 12 import "core:fmt" 13 import "core:slice" 14 import "core:strings" 15 import "core:text/regex" 16 17 import "../change" 18 import "../check" 19 import "../finding" 20 import "../txt" 21 22 // Job is one reading. subject renders the part of the change it reads; 23 // an empty subject means there is nothing here for it and the job is 24 // skipped. splittable is whether the subject can be read file by file: a 25 // subject over the packet cap is then asked in parts. 26 Job :: struct { 27 name: string, 28 criteria: string, 29 subject: proc(c: ^change.Change, allocator: runtime.Allocator) -> string, 30 splittable: bool, 31 } 32 33 // all is the readings, in the order their findings are worth having. 34 all :: proc(allocator := context.temp_allocator) -> []Job { 35 jobs := make([]Job, 5, allocator) 36 jobs[0] = Job { 37 "duplication", 38 #load("../criteria/duplication.md", string), 39 duplication_subject, 40 true, 41 } 42 jobs[1] = Job{"tests", #load("../criteria/tests.md", string), tests_subject, true} 43 jobs[2] = Job{"namer", #load("../criteria/namer.md", string), namer_subject, true} 44 jobs[3] = Job{"claims", #load("../criteria/claims.md", string), claims_subject, true} 45 jobs[4] = Job{"hygiene", #load("../criteria/hygiene.md", string), hygiene_subject, false} 46 return jobs 47 } 48 49 // chosen is the jobs named, comma separated, or all of them. 50 chosen :: proc(only: string, allocator := context.temp_allocator) -> (jobs: []Job, err: string) { 51 if strings.trim_space(only) == "" { 52 return all(allocator), "" 53 } 54 picked := make([dynamic]Job, allocator) 55 for name in strings.split(only, ",", context.temp_allocator) { 56 want := strings.trim_space(name) 57 found := false 58 for j in all(allocator) { 59 if j.name == want { 60 append(&picked, j) 61 found = true 62 } 63 } 64 if !found { 65 return nil, fmt.aprintf( 66 "no job called %q; the jobs are claims, duplication, hygiene, namer, tests", 67 want, 68 allocator = allocator, 69 ) 70 } 71 } 72 return picked[:], "" 73 } 74 75 // rules reads the ids a job may cite out of its own criteria: the bullets 76 // opening with a backticked id. 77 rules :: proc(criteria: string, allocator := context.temp_allocator) -> map[string]bool { 78 out := make(map[string]bool, allocator) 79 rest := criteria 80 for line in strings.split_lines_iterator(&rest) { 81 if !strings.has_prefix(line, "- `") { 82 continue 83 } 84 end := strings.index_byte(line[3:], '`') 85 if end < 0 { 86 continue 87 } 88 id := line[3:3 + end] 89 valid := len(id) > 0 90 for i in 0 ..< len(id) { 91 c := id[i] 92 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { 93 valid = false 94 } 95 } 96 if valid { 97 out[id] = true 98 } 99 } 100 return out 101 } 102 103 namer_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string { 104 if len(c.symbols) == 0 { 105 return "" 106 } 107 b := strings.builder_make(allocator) 108 strings.write_string(&b, "Names this change adds or renames:\n\n") 109 for s in c.symbols { 110 fmt.sbprintf(&b, "%s:%d %s %s", s.file, s.line, s.kind, s.name) 111 if s.exported { 112 strings.write_string(&b, " (exported)") 113 } 114 strings.write_string(&b, "\n") 115 if s.signature != "" { 116 fmt.sbprintf(&b, " %s\n", s.signature) 117 } 118 if s.doc != "" { 119 fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator)) 120 } 121 if near := c.candidates[s.name]; len(near) > 0 { 122 fmt.sbprintf( 123 &b, 124 " names already in this repository: %s\n", 125 strings.join(near[:min(len(near), 6)], "; ", context.temp_allocator), 126 ) 127 } 128 strings.write_string(&b, "\n") 129 } 130 return strings.to_string(b) 131 } 132 133 duplication_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string { 134 if len(c.symbols) == 0 { 135 return "" 136 } 137 b := strings.builder_make(allocator) 138 // The pairs holding the same literal go first and alone. Buried among 139 // the resemblances a reading finds one of them and stops. 140 if twinned := twins(c, context.temp_allocator); twinned != "" { 141 strings.write_string( 142 &b, 143 "Declarations this change adds that hold a value already declared elsewhere.\n", 144 ) 145 strings.write_string(&b, "Judge every pair on this list.\n\n") 146 strings.write_string(&b, twinned) 147 strings.write_string(&b, "\n") 148 } 149 strings.write_string( 150 &b, 151 "Each name the change adds, with existing declarations found by searching for its words.\n\n", 152 ) 153 for s in c.symbols { 154 fmt.sbprintf(&b, "NEW %s:%d %s %s\n", s.file, s.line, s.kind, s.name) 155 if s.signature != "" { 156 fmt.sbprintf(&b, " %s\n", s.signature) 157 } 158 if s.doc != "" { 159 fmt.sbprintf(&b, " doc: %s\n", check.first_line(s.doc, context.temp_allocator)) 160 } 161 candidates := c.candidates[s.name] 162 if len(candidates) == 0 { 163 strings.write_string(&b, " candidates: none found\n\n") 164 continue 165 } 166 strings.write_string(&b, " candidates:\n") 167 for line in candidates { 168 fmt.sbprintf(&b, " %s\n", line) 169 } 170 strings.write_string(&b, "\n") 171 } 172 return strings.to_string(b) 173 } 174 175 // twins renders the declarations whose value already exists, in the 176 // order the change declares them. 177 twins :: proc(c: ^change.Change, allocator := context.allocator) -> string { 178 b := strings.builder_make(allocator) 179 for s in c.symbols { 180 lines := c.twins[s.name] 181 if len(lines) == 0 { 182 continue 183 } 184 fmt.sbprintf(&b, " %s:%d %s\n", s.file, s.line, s.signature) 185 for line in lines { 186 fmt.sbprintf(&b, " %s\n", strings.trim_suffix(line, " <- same value")) 187 } 188 } 189 return strings.to_string(b) 190 } 191 192 tests_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string { 193 if len(c.tests) == 0 { 194 return "" 195 } 196 b := strings.builder_make(allocator) 197 strings.write_string(&b, "Test functions this change adds or alters:\n\n") 198 for t in c.tests { 199 fmt.sbprintf(&b, "--- %s:%d %s", t.file, t.line, t.name) 200 if t.skips > 0 { 201 // The skip is pointed at rather than left to be found, so the 202 // reading spends itself on whether the skip is ordinary. 203 fmt.sbprintf(&b, " (skips itself at line %d)", t.skips) 204 } 205 fmt.sbprintf(&b, "\n%s\n\n", t.body) 206 } 207 if called := functions_under_test(c, context.temp_allocator); called != "" { 208 strings.write_string( 209 &b, 210 "Functions the tests call, as they stand at the end of the change. A test that\n", 211 ) 212 strings.write_string( 213 &b, 214 "would pass with one of these returning its input or a zero value is the finding.\n\n", 215 ) 216 strings.write_string(&b, called) 217 } 218 return strings.to_string(b) 219 } 220 221 // The bounds on what the tests job is shown of the code under test: how 222 // many functions, and how long each may be before it is cut. 223 called_functions :: 8 224 called_lines :: 60 225 226 // functions_under_test renders the functions the tests call, found by 227 // name in the repository's index, so that whether a test would pass on a 228 // stub is judged against the function rather than guessed from the test. 229 // A helper a test file declares is not the code under test. 230 functions_under_test :: proc(c: ^change.Change, allocator := context.allocator) -> string { 231 if len(c.index) == 0 { 232 return "" 233 } 234 declared := make(map[string]change.Declared, context.temp_allocator) 235 for d in c.index { 236 if d.kind == "func" && d.body != "" && !check.is_test_file(d.file) { 237 if d.name not_in declared { 238 declared[d.name] = d 239 } 240 } 241 } 242 seen := make(map[string]bool, context.temp_allocator) 243 shown := make([dynamic]change.Declared, context.temp_allocator) 244 outer: for t in c.tests { 245 for name in calls(t.body, context.temp_allocator) { 246 d, ok := declared[name] 247 if !ok || seen[name] || name == t.name { 248 continue 249 } 250 seen[name] = true 251 append(&shown, d) 252 if len(shown) == called_functions { 253 break outer 254 } 255 } 256 } 257 if len(shown) == 0 { 258 return "" 259 } 260 b := strings.builder_make(allocator) 261 for d in shown { 262 body := d.body 263 lines := strings.split_lines(body, context.temp_allocator) 264 if len(lines) > called_lines { 265 body = fmt.tprintf( 266 "%s\n\t… cut at %d lines", 267 strings.join(lines[:called_lines], "\n", context.temp_allocator), 268 called_lines, 269 ) 270 } 271 fmt.sbprintf(&b, "--- %s:%d %s\n%s\n\n", d.file, d.line, d.name, body) 272 } 273 return strings.to_string(b) 274 } 275 276 // calls are the names a body calls, in order, which is how a test names 277 // what it tests. 278 calls :: proc(body: string, allocator := context.allocator) -> []string { 279 out := make([dynamic]string, allocator) 280 it, err := regex.create_iterator( 281 body, 282 `\b([A-Za-z_][A-Za-z0-9_]*)\(`, 283 {}, 284 context.temp_allocator, 285 ) 286 if err != nil { 287 return out[:] 288 } 289 defer regex.destroy_iterator(it, context.temp_allocator) 290 for { 291 cap, _, ok := regex.match_iterator(&it) 292 if !ok { 293 break 294 } 295 append(&out, strings.trim_suffix(cap.groups[0], "(")) 296 } 297 return out[:] 298 } 299 300 claims_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string { 301 blocks := comment_blocks(c.comments[:], context.temp_allocator) 302 if len(blocks) == 0 { 303 return "" 304 } 305 b := strings.builder_make(allocator) 306 strings.write_string( 307 &b, 308 "Comment and documentation lines this change adds, each with the code beneath it:\n\n", 309 ) 310 for block in blocks { 311 for comment in block { 312 fmt.sbprintf(&b, "%s:%d %s\n", comment.file, comment.line, comment.text) 313 } 314 if below := block[len(block) - 1].below; below != "" { 315 rest := below 316 for line in strings.split_lines_iterator(&rest) { 317 fmt.sbprintf(&b, " code: %s\n", line) 318 } 319 } 320 strings.write_string(&b, "\n") 321 } 322 return strings.to_string(b) 323 } 324 325 // comment_blocks groups the comments into the runs of consecutive lines 326 // they were written as, so a claim read over three lines is read whole 327 // and the code below it is shown once. A comment whose words are the 328 // code's own is left out: it is never a claim, and measured elsewhere. 329 comment_blocks :: proc( 330 comments: []change.Located, 331 allocator := context.allocator, 332 ) -> [][]change.Located { 333 blocks := make([dynamic][]change.Located, allocator) 334 current := make([dynamic]change.Located, allocator) 335 for comment in comments { 336 if check.restates(comment) { 337 continue 338 } 339 if len(current) > 0 { 340 last := current[len(current) - 1] 341 if !(last.file == comment.file && last.line + 1 == comment.line) { 342 append(&blocks, current[:]) 343 current = make([dynamic]change.Located, allocator) 344 } 345 } 346 append(¤t, comment) 347 } 348 if len(current) > 0 { 349 append(&blocks, current[:]) 350 } 351 return blocks[:] 352 } 353 354 hygiene_subject :: proc(c: ^change.Change, allocator: runtime.Allocator) -> string { 355 if strings.trim_space(c.message) == "" { 356 return "" 357 } 358 b := strings.builder_make(allocator) 359 strings.write_string(&b, "Commit message:\n\n") 360 strings.write_string(&b, c.message) 361 strings.write_string(&b, "\n\nFiles changed:\n") 362 strings.write_string(&b, c.stat) 363 if len(c.convention) > 0 { 364 strings.write_string( 365 &b, 366 "\nRecent subjects in this repository, as the local convention:\n", 367 ) 368 for subject in c.convention { 369 fmt.sbprintf(&b, " %s\n", subject) 370 } 371 } 372 return strings.to_string(b) 373 } 374 375 // Reported is the shape a job answers in. 376 Reported :: struct { 377 findings: []struct { 378 rule: string `json:"rule"`, 379 severity: string `json:"severity"`, 380 file: string `json:"file"`, 381 line: int `json:"line"`, 382 symbol: string `json:"symbol"`, 383 message: string `json:"message"`, 384 fix: string `json:"fix"`, 385 } `json:"findings"`, 386 } 387 388 // decode reads a job's answer. A finding that cites no rule from the 389 // criteria is dropped: the criteria are what gets tuned, so a job may not 390 // invent one. 391 decode :: proc( 392 raw: string, 393 name: string, 394 allowed: map[string]bool, 395 allocator := context.allocator, 396 ) -> ( 397 out: []finding.Finding, 398 ok: bool, 399 ) { 400 r: Reported 401 if json.unmarshal_string(raw, &r, allocator = context.temp_allocator) != nil { 402 return nil, false 403 } 404 kept := make([dynamic]finding.Finding, allocator) 405 for f in r.findings { 406 if !allowed[f.rule] { 407 continue 408 } 409 append( 410 &kept, 411 finding.Finding { 412 job = strings.clone(name, allocator), 413 rule = strings.clone(f.rule, allocator), 414 severity = finding.parse_severity(f.severity), 415 severity_name = strings.clone(f.severity, allocator), 416 file = strings.clone(f.file, allocator), 417 line = f.line, 418 symbol = strings.clone(f.symbol, allocator), 419 message = strings.clone(f.message, allocator), 420 fix = strings.clone(f.fix, allocator), 421 }, 422 ) 423 } 424 return kept[:], true 425 } 426 427 // readable is whether an answer holds a findings object at all. 428 readable :: proc(text: string) -> bool { 429 raw, found := txt.object(text) 430 if !found { 431 return false 432 } 433 r: Reported 434 return json.unmarshal_string(raw, &r, allocator = context.temp_allocator) == nil 435 } 436 437 // packet_cap is the size of subject past which a splittable job is asked 438 // in parts. Under the cap each part is an ask a slow gateway finishes, 439 // and a part whose files did not change replays from the cache. 440 packet_cap :: 16000 441 442 // parts is the subjects a job is asked, as changes: the whole change when 443 // it fits or cannot be split, else the change cut file by file into runs 444 // that each render under the cap. A file the job reads nothing from — a 445 // test file, for the duplication job — is left out of every run, so no 446 // part is ever asked with an empty subject. A file that alone renders 447 // over the cap is a part by itself. 448 parts :: proc(j: Job, c: ^change.Change, allocator := context.allocator) -> []^change.Change { 449 out := make([dynamic]^change.Change, allocator) 450 if !j.splittable || len(j.subject(c, context.temp_allocator)) <= packet_cap { 451 append(&out, c) 452 return out[:] 453 } 454 group := make([dynamic]string, context.temp_allocator) 455 for file in c.files { 456 alone := make([]string, 1, context.temp_allocator) 457 alone[0] = file 458 if strings.trim_space(j.subject(part(c, alone, context.temp_allocator), context.temp_allocator)) == "" { 459 continue 460 } 461 if len(group) > 0 { 462 with := make([dynamic]string, context.temp_allocator) 463 append(&with, ..group[:]) 464 append(&with, file) 465 piece := part(c, with[:], context.temp_allocator) 466 if len(j.subject(piece, context.temp_allocator)) > packet_cap { 467 append(&out, part(c, group[:], allocator)) 468 clear(&group) 469 } 470 } 471 append(&group, file) 472 } 473 if len(group) > 0 { 474 append(&out, part(c, group[:], allocator)) 475 } 476 return out[:] 477 } 478 479 // part is the change narrowed to some of its files: the declarations, 480 // tests and comments in them, with everything the jobs read beside those 481 // — candidates, twins, the index, the message — shared. 482 part :: proc( 483 c: ^change.Change, 484 files: []string, 485 allocator := context.allocator, 486 ) -> ^change.Change { 487 keep := make(map[string]bool, context.temp_allocator) 488 for f in files { 489 keep[f] = true 490 } 491 p := new(change.Change, allocator) 492 p^ = c^ 493 p.files = slice.clone(files, allocator) 494 p.symbols = make([dynamic]change.Symbol, allocator) 495 p.tests = make([dynamic]change.Function, allocator) 496 p.comments = make([dynamic]change.Located, allocator) 497 for s in c.symbols { 498 if keep[s.file] { 499 append(&p.symbols, s) 500 } 501 } 502 for t in c.tests { 503 if keep[t.file] { 504 append(&p.tests, t) 505 } 506 } 507 for comment in c.comments { 508 if keep[comment.file] { 509 append(&p.comments, comment) 510 } 511 } 512 return p 513 }