review

review patchsets using your default editor
Log | Files | Refs

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 }