reviewer.odin (16403B)
1 /* 2 Package reviewer runs the jobs against a change, through whichever 3 provider answers. Each job's findings are put back to the provider once, 4 against the same evidence, and the ones a second reading does not let 5 stand are retracted rather than reported. The cache records what each ask 6 answered, so a re-run of an unchanged part of the change replays instead 7 of asking. What every job is told is the Go tool's, word for word, so the 8 two tools share one cache. 9 */ 10 package reviewer 11 12 import "base:runtime" 13 import "core:encoding/json" 14 import "core:fmt" 15 import "core:os" 16 import "core:strings" 17 import "core:sync" 18 import "core:thread" 19 20 import "../cache" 21 import "../change" 22 import "../finding" 23 import "../job" 24 import "../provider" 25 import "../report" 26 import "../txt" 27 28 // instruction is what every job is told, before its own criteria. It is 29 // kept identical across jobs and providers, so the only thing that differs 30 // between two readings is the criteria and the subject. 31 instruction :: `You are reviewing one narrow aspect of a change to a repository. 32 33 Report only what the criteria below cover. Everything else is another reader's 34 job: say nothing about formatting, style, performance, or correctness unless a 35 criterion names it. 36 37 Rules for reporting: 38 - Every finding cites one rule id from the criteria, exactly as written. A 39 finding citing anything else is discarded. 40 - Severity is must-fix when the criterion is plainly broken, consider when it 41 is a judgement call, note otherwise. 42 - A finding names the file and line it concerns where it has one. 43 - The fix is the concrete change to make, not a restatement of the problem. 44 - Reporting nothing is the right answer when the criteria are met. Do not 45 manufacture findings to appear useful. 46 - You are shown only part of the change. Never infer what the rest contains. 47 48 Answer with one JSON object and nothing else. No preamble, no explanation, no 49 code fence: 50 51 {"findings":[{"rule":"","severity":"must-fix|consider|note","file":"","line":0,"symbol":"","message":"","fix":""}]} 52 53 Report no findings as {"findings":[]}.` 54 55 // verify_instruction is what the second reading is told. It sees what 56 // the first one saw and nothing the first one concluded beyond the 57 // findings themselves, so that a verdict is a reading of the evidence 58 // rather than an agreement with a colleague. 59 verify_instruction :: `You are checking findings another reviewer made against one narrow aspect of a change. 60 61 You are shown the criteria that reader judged against, the same part of the 62 change it read, and the findings it reported. For each finding, decide 63 whether it holds: the code it points at must meet the fault its rule 64 describes, judged from the evidence in front of you. 65 66 - Judge every finding, by its number. Report no verdicts but those. 67 - holds is false for a finding you would not report yourself from this 68 evidence; say why in reason, in one sentence. 69 - A finding that holds but overstates its case does not hold as written. 70 71 Answer with one JSON object and nothing else. No preamble, no explanation, no 72 code fence: 73 74 {"verdicts":[{"index":0,"holds":true,"reason":""}]}` 75 76 // again is what a job is told when it answered in prose; verdicts_again 77 // the same for the second reading. 78 again :: `Your previous answer was not a JSON object, so nothing was read from it. 79 Answer again, with the findings you already made, as one JSON object and nothing else.` 80 81 verdicts_again :: `Your previous answer was not a JSON object, so nothing was read from it. 82 Answer again, with one verdict per finding, as one JSON object and nothing else.` 83 84 // Reviewer is how the jobs are asked: the provider, whether to verify, 85 // whether to narrate, and the cache to replay from. 86 Reviewer :: struct { 87 provider: provider.Provider, 88 name: string, 89 verify: bool, 90 verbose: bool, 91 cache: ^cache.Cache, 92 } 93 94 // Result is what a set of readings produced. The parts beyond the 95 // findings are what a program reading the JSON needs to trust an empty 96 // list: which jobs failed, which had nothing to read, and what the 97 // asking cost. 98 Result :: struct { 99 findings: [dynamic]finding.Finding, 100 failures: [dynamic]string, 101 retracted: [dynamic]report.Retracted, 102 skipped: [dynamic]string, 103 tokens_in: int, 104 tokens_out: int, 105 cached: int, 106 replayed: int, 107 cost: f64, 108 } 109 110 // Task is one job's reading, as a thread carries it. 111 @(private) 112 Task :: struct { 113 r: ^Reviewer, 114 j: job.Job, 115 pieces: []^change.Change, 116 result: ^Result, 117 lock: ^sync.Mutex, 118 } 119 120 // run works every job that has something to read, at once. One job 121 // failing does not stop the others: four readings out of five is worth 122 // more than none. REVIEW_SERIAL asks them one at a time instead, for 123 // providers that cannot take concurrent reads. 124 run :: proc( 125 r: ^Reviewer, 126 c: ^change.Change, 127 jobs: []job.Job, 128 serial := false, 129 allocator := context.allocator, 130 ) -> Result { 131 context.allocator = allocator 132 result := Result { 133 findings = make([dynamic]finding.Finding), 134 failures = make([dynamic]string), 135 retracted = make([dynamic]report.Retracted), 136 skipped = make([dynamic]string), 137 } 138 lock: sync.Mutex 139 // Every job's parts are cut once, and the same cut serves the second 140 // reading, so a verdict is asked against exactly what the finding was 141 // read from. 142 tasks := make([]Task, len(jobs), context.temp_allocator) 143 for j, i in jobs { 144 tasks[i] = Task { 145 r = r, 146 j = j, 147 pieces = job.parts(j, c, allocator), 148 result = &result, 149 lock = &lock, 150 } 151 } 152 one_at_a_time := serial || os.get_env("REVIEW_SERIAL", context.temp_allocator) != "" 153 if one_at_a_time { 154 for &t in tasks { 155 read(&t) 156 } 157 } else { 158 threads := make([dynamic]^thread.Thread, context.temp_allocator) 159 for &t in tasks { 160 ctx := runtime.default_context() 161 ctx.allocator = allocator 162 append(&threads, thread.create_and_start_with_poly_data(&t, read, ctx)) 163 } 164 for th in threads { 165 thread.join(th) 166 thread.destroy(th) 167 } 168 } 169 // The skipped jobs are named in the jobs' own order, whichever 170 // thread said so first. 171 ordered := make([dynamic]string, allocator) 172 for j in jobs { 173 for name in result.skipped { 174 if name == j.name { 175 append(&ordered, name) 176 } 177 } 178 } 179 result.skipped = ordered 180 if r.verify { 181 verify(r, tasks, &result, one_at_a_time, allocator) 182 } 183 return result 184 } 185 186 // read is one job's reading: nothing when there is nothing to read, else 187 // each part asked and recorded. 188 @(private) 189 read :: proc(t: ^Task) { 190 subject := t.j.subject(t.pieces[0], context.temp_allocator) 191 whole := len(t.pieces) == 1 192 if whole && strings.trim_space(subject) == "" { 193 sync.mutex_lock(t.lock) 194 append(&t.result.skipped, strings.clone(t.j.name)) 195 sync.mutex_unlock(t.lock) 196 if t.r.verbose { 197 fmt.printfln(" %-12s nothing to read", t.j.name) 198 } 199 return 200 } 201 if t.r.verbose && len(t.pieces) > 1 { 202 fmt.printfln(" %-12s asked in %d parts", t.j.name, len(t.pieces)) 203 } 204 for piece, i in t.pieces { 205 user := t.j.subject(piece, context.temp_allocator) 206 if strings.trim_space(user) == "" { 207 continue // A part with nothing in it is not a question. 208 } 209 found, answer, err := ask(t.r, t.j, user) 210 for &f in found { 211 f.part = i 212 } 213 if err != "" && len(t.pieces) > 1 { 214 err = fmt.tprintf("part %d of %d: %s", i + 1, len(t.pieces), err) 215 } 216 record(t.result, t.lock, t.j.name, found, answer, err) 217 } 218 } 219 220 // record keeps one ask's findings or failure, and what it cost. 221 @(private) 222 record :: proc( 223 result: ^Result, 224 lock: ^sync.Mutex, 225 name: string, 226 found: []finding.Finding, 227 answer: provider.Answer, 228 err: string, 229 ) { 230 sync.mutex_lock(lock) 231 defer sync.mutex_unlock(lock) 232 result.tokens_in += answer.tokens_in 233 result.tokens_out += answer.tokens_out 234 result.cached += answer.cached 235 result.cost += answer.cost 236 if answer.replayed { 237 result.replayed += 1 238 } 239 if err != "" { 240 append(&result.failures, fmt.aprintf("%s: %s", name, err)) 241 return 242 } 243 append(&result.findings, ..found) 244 } 245 246 // ask puts one job's question. The criteria are the system prompt, where 247 // a provider that caches anything will cache them; the subject goes last. 248 // An answer in prose is asked again once. 249 @(private) 250 ask :: proc( 251 r: ^Reviewer, 252 j: job.Job, 253 subject: string, 254 ) -> ( 255 found: []finding.Finding, 256 answer: provider.Answer, 257 err: string, 258 ) { 259 system := strings.concatenate({instruction, "\n\n", j.criteria}, context.temp_allocator) 260 answer, err = answered(r, system, subject, false, job.readable) 261 if err != "" { 262 return nil, {}, err 263 } 264 spent(r, j.name, answer) 265 raw, found_object := txt.object(answer.text) 266 if !found_object { 267 prose := provider.first(answer.text, 200) 268 answer, err = answered( 269 r, 270 system, 271 strings.concatenate({subject, "\n\n", again}, context.temp_allocator), 272 false, 273 job.readable, 274 ) 275 if err != "" { 276 return nil, {}, err 277 } 278 spent(r, j.name, answer) 279 raw, found_object = txt.object(answer.text) 280 if !found_object { 281 return nil, answer, fmt.aprintf("no findings object in the answer, twice: %s", prose) 282 } 283 } 284 decoded, ok := job.decode(raw, j.name, job.rules(j.criteria)) 285 if !ok { 286 return nil, answer, "reading the answer: the findings are not the shape asked for" 287 } 288 return decoded, answer, "" 289 } 290 291 // answered asks through the cache: the same question asked of the same 292 // provider is replayed rather than asked. Only an answer the caller can 293 // read is recorded: a model that spent its whole budget and said nothing 294 // would otherwise be replayed on every run. 295 @(private) 296 answered :: proc( 297 r: ^Reviewer, 298 system, user: string, 299 verdict: bool, 300 readable: proc(text: string) -> bool, 301 ) -> ( 302 provider.Answer, 303 string, 304 ) { 305 if os.get_env("REVIEW_DEBUG_CACHE", context.temp_allocator) != "" { 306 fmt.eprintfln( 307 "cache key %s system=%d user=%d provider=%s", 308 cache.key(r.name, system, user, context.temp_allocator)[:12], 309 len(system), 310 len(user), 311 r.name, 312 ) 313 } 314 if hit, replayed := cache.get(r.cache, r.name, system, user); replayed { 315 if r.verbose { 316 fmt.printfln(" %-12s replayed", r.name) 317 } 318 return hit, "" 319 } 320 answer: provider.Answer 321 err: string 322 if verdict { 323 answer, err = provider.ask_verdict(r.provider, system, user) 324 } else { 325 answer, err = provider.ask(r.provider, system, user) 326 } 327 if err == "" && readable(answer.text) { 328 cache.put(r.cache, r.name, system, user, answer) 329 } 330 return answer, err 331 } 332 333 @(private) 334 spent :: proc(r: ^Reviewer, name: string, answer: provider.Answer) { 335 if !r.verbose { 336 return 337 } 338 if answer.replayed { 339 fmt.printfln(" %-12s replayed", name) 340 return 341 } 342 fmt.printfln( 343 " %-12s %d in, %d out, %d cached, $%.4f", 344 name, 345 answer.tokens_in, 346 answer.tokens_out, 347 answer.cached, 348 answer.cost, 349 ) 350 } 351 352 // Group is one job's findings from one part of its subject, for the pass 353 // that checks them. 354 @(private) 355 Group :: struct { 356 r: ^Reviewer, 357 j: job.Job, 358 subject: string, 359 indexes: []int, 360 result: ^Result, 361 lock: ^sync.Mutex, 362 drop: ^map[int]bool, 363 } 364 365 // verify puts each job's findings back to the provider once, against the 366 // same evidence the first reading had, and keeps only the ones it also 367 // reports. A verdict that cannot be asked fails open: the findings stand, 368 // marked unverified, and the failure joins the others. A note is never 369 // gated on, so it stands unverified without a second reading. 370 @(private) 371 verify :: proc( 372 r: ^Reviewer, 373 tasks: []Task, 374 result: ^Result, 375 serial: bool, 376 allocator := context.allocator, 377 ) { 378 groups := make([dynamic]Group, context.temp_allocator) 379 lock: sync.Mutex 380 drop := make(map[int]bool, context.temp_allocator) 381 for t in tasks { 382 for piece, part in t.pieces { 383 indexes := make([dynamic]int, context.temp_allocator) 384 for f, i in result.findings { 385 if f.job == t.j.name && f.part == part && f.severity != .Note { 386 append(&indexes, i) 387 } 388 } 389 if len(indexes) > 0 { 390 append( 391 &groups, 392 Group { 393 r, 394 t.j, 395 t.j.subject(piece, context.temp_allocator), 396 indexes[:], 397 result, 398 &lock, 399 &drop, 400 }, 401 ) 402 } 403 } 404 } 405 if len(groups) == 0 { 406 return 407 } 408 if serial { 409 for &g in groups { 410 judge(&g) 411 } 412 } else { 413 threads := make([dynamic]^thread.Thread, context.temp_allocator) 414 for &g in groups { 415 ctx := runtime.default_context() 416 ctx.allocator = allocator 417 append(&threads, thread.create_and_start_with_poly_data(&g, judge, ctx)) 418 } 419 for th in threads { 420 thread.join(th) 421 thread.destroy(th) 422 } 423 } 424 // A retracted finding is out of the findings: it is reported as a 425 // retraction, with its reason, not twice over. 426 if len(drop) == 0 { 427 return 428 } 429 survivors := make([dynamic]finding.Finding, allocator) 430 for f, i in result.findings { 431 if !drop[i] { 432 append(&survivors, f) 433 } 434 } 435 result.findings = survivors 436 } 437 438 // judge asks one group's verdicts and applies them. 439 @(private) 440 judge :: proc(g: ^Group) { 441 held, answer, err := verdicts(g.r, g.j, g.subject, g.result.findings[:], g.indexes) 442 sync.mutex_lock(g.lock) 443 defer sync.mutex_unlock(g.lock) 444 g.result.tokens_in += answer.tokens_in 445 g.result.tokens_out += answer.tokens_out 446 g.result.cached += answer.cached 447 g.result.cost += answer.cost 448 if answer.replayed { 449 g.result.replayed += 1 450 } 451 if err != "" { 452 append(&g.result.failures, fmt.aprintf("verify/%s: %s", g.j.name, err)) 453 for i in g.indexes { 454 g.result.findings[i].verified = false 455 } 456 return 457 } 458 for i, position in g.indexes { 459 if v, judged := held[position]; judged && !v.holds { 460 append( 461 &g.result.retracted, 462 report.Retracted{finding = g.result.findings[i], reason = v.reason}, 463 ) 464 g.drop[i] = true 465 continue 466 } 467 // A finding the verdict list omits stands rather than falls: a 468 // strict pass would let one dropped number retract everything 469 // the reading found. 470 g.result.findings[i].verified = true 471 } 472 } 473 474 // Verdict is what the second reading says about one finding. 475 Verdict :: struct { 476 holds: bool, 477 reason: string, 478 } 479 480 // Spoken_Verdicts is the shape the second reading answers in. 481 Spoken_Verdicts :: struct { 482 verdicts: []struct { 483 index: int `json:"index"`, 484 holds: bool `json:"holds"`, 485 reason: string `json:"reason"`, 486 } `json:"verdicts"`, 487 } 488 489 // readable_verdicts is whether an answer holds a verdicts object. 490 readable_verdicts :: proc(text: string) -> bool { 491 raw, found := txt.object(text) 492 if !found { 493 return false 494 } 495 read: Spoken_Verdicts 496 return json.unmarshal_string(raw, &read, allocator = context.temp_allocator) == nil 497 } 498 499 // verdicts puts one job's findings back to the provider, against the same 500 // evidence the first reading had. Verdicts are keyed by position in the 501 // listing; a position the answer omits stands rather than falls. 502 @(private) 503 verdicts :: proc( 504 r: ^Reviewer, 505 j: job.Job, 506 subject: string, 507 findings: []finding.Finding, 508 indexes: []int, 509 ) -> ( 510 held: map[int]Verdict, 511 answer: provider.Answer, 512 err: string, 513 ) { 514 system := strings.concatenate({verify_instruction, "\n\n", j.criteria}, context.temp_allocator) 515 listed := strings.builder_make(context.temp_allocator) 516 for i, position in indexes { 517 f := findings[i] 518 where_at := "" 519 if f.file != "" { 520 where_at = fmt.tprintf(" at %s:%d", f.file, f.line) 521 } 522 fmt.sbprintf( 523 &listed, 524 "%d. [%s] %s%s: %s\n fix: %s\n", 525 position, 526 f.rule, 527 finding.severity_name(f.severity), 528 where_at, 529 f.message, 530 f.fix, 531 ) 532 } 533 user := strings.concatenate( 534 {subject, "\n\nThe findings reported against it:\n\n", strings.to_string(listed)}, 535 context.temp_allocator, 536 ) 537 answer, err = answered(r, system, user, true, readable_verdicts) 538 if err != "" { 539 return nil, answer, err 540 } 541 spent(r, j.name, answer) 542 raised, found := txt.object(answer.text) 543 if !found { 544 prose := provider.first(answer.text, 200) 545 answer, err = answered( 546 r, 547 system, 548 strings.concatenate({user, "\n\n", verdicts_again}, context.temp_allocator), 549 true, 550 readable_verdicts, 551 ) 552 if err != "" { 553 return nil, answer, err 554 } 555 spent(r, j.name, answer) 556 raised, found = txt.object(answer.text) 557 if !found { 558 return nil, answer, fmt.aprintf("no verdicts object in the answer, twice: %s", prose) 559 } 560 } 561 read: Spoken_Verdicts 562 if json.unmarshal_string(raised, &read, allocator = context.temp_allocator) != nil { 563 return nil, answer, "the verdicts are not the shape asked for" 564 } 565 held = make(map[int]Verdict, context.temp_allocator) 566 for v in read.verdicts { 567 if v.index >= 0 && v.index < len(indexes) { 568 held[v.index] = Verdict{v.holds, strings.clone(v.reason)} 569 } 570 } 571 return held, answer, "" 572 }