review

review patchsets using your default editor
Log | Files | Refs

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(&current, 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 }