review

review patchsets using your default editor
Log | Files | Refs

finding.odin (8693B)


      1 /*
      2 Package finding is one thing a reading noticed: what it concerns, how
      3 seriously to take it, and the stable name a loop refers to it by. It also
      4 reads the dismissal a source file carries beside what it justifies.
      5 */
      6 package finding
      7 
      8 import "core:crypto/sha2"
      9 import "core:encoding/hex"
     10 import "core:fmt"
     11 import "core:os"
     12 import "core:path/filepath"
     13 import "core:slice"
     14 import "core:strconv"
     15 import "core:strings"
     16 
     17 // Severity is what a finding means for the change, most serious first.
     18 // Only Must_Fix is worth blocking on; the rest are reported and left to
     19 // judgement, because a loop that treats taste as an error never finishes.
     20 Severity :: enum {
     21 	Must_Fix,
     22 	Consider,
     23 	Note,
     24 }
     25 
     26 severity_name :: proc(s: Severity) -> string {
     27 	switch s {
     28 	case .Must_Fix:
     29 		return "must-fix"
     30 	case .Consider:
     31 		return "consider"
     32 	case .Note:
     33 		return "note"
     34 	}
     35 	return "unknown"
     36 }
     37 
     38 // parse_severity reads a severity the way a job reports it, defaulting to
     39 // the least serious rather than failing: a job that invents a word should
     40 // not stop the run.
     41 parse_severity :: proc(s: string) -> Severity {
     42 	switch strings.to_lower(strings.trim_space(s), context.temp_allocator) {
     43 	case "must-fix", "must fix", "mustfix", "error":
     44 		return .Must_Fix
     45 	case "consider", "warning":
     46 		return .Consider
     47 	}
     48 	return .Note
     49 }
     50 
     51 // Finding is one thing a job noticed. The JSON names are the contract's,
     52 // shared with the Go tool, so a loop reads either report the same way.
     53 Finding :: struct {
     54 	// job is the job that reported it; rule the criterion it was judged
     55 	// against. A finding that cites no rule is dropped, so that the
     56 	// criteria are what gets tuned rather than the prompt.
     57 	job:           string `json:"job"`,
     58 	rule:          string `json:"rule"`,
     59 	severity:      Severity `json:"-"`,
     60 	// severity_name is how the severity travels in and out of JSON.
     61 	severity_name: string `json:"severity"`,
     62 	// file and line locate it, where it has a location; symbol names what
     63 	// it concerns, for the findings that are about a name rather than a
     64 	// place.
     65 	file:          string `json:"file,omitempty"`,
     66 	line:          int `json:"line,omitempty"`,
     67 	symbol:        string `json:"symbol,omitempty"`,
     68 	message:       string `json:"message"`,
     69 	// fix is the concrete change suggested, which is what lets an agent
     70 	// act on the finding rather than reason about it again; snippet is the
     71 	// line the finding points at, as it stands.
     72 	fix:           string `json:"fix,omitempty"`,
     73 	snippet:       string `json:"snippet,omitempty"`,
     74 	// verified is what stands behind the finding: the deterministic checks
     75 	// verify themselves; a model's finding is verified when a second
     76 	// reading of the same evidence let it stand.
     77 	verified:      bool `json:"verified"`,
     78 	// id is a stable short name: a hash of what the finding is about, not
     79 	// where it sits, so that a re-run names the same finding again.
     80 	id:            string `json:"id,omitempty"`,
     81 	// part is which part of a split subject the finding came from.
     82 	part:          int `json:"-"`,
     83 }
     84 
     85 // to_string is the finding as a person reads it.
     86 to_string :: proc(f: Finding, allocator := context.allocator) -> string {
     87 	b := strings.builder_make(allocator)
     88 	strings.write_string(&b, severity_name(f.severity))
     89 	strings.write_string(&b, ": ")
     90 	if f.file != "" {
     91 		strings.write_string(&b, f.file)
     92 		if f.line > 0 {
     93 			fmt.sbprintf(&b, ":%d", f.line)
     94 		}
     95 		strings.write_string(&b, ": ")
     96 	} else if f.symbol != "" {
     97 		strings.write_string(&b, f.symbol)
     98 		strings.write_string(&b, ": ")
     99 	}
    100 	strings.write_string(&b, f.message)
    101 	if f.fix != "" {
    102 		strings.write_string(&b, "\n      → ")
    103 		strings.write_string(&b, f.fix)
    104 	}
    105 	strings.write_string(&b, "\n      [")
    106 	strings.write_string(&b, f.job)
    107 	strings.write_string(&b, "/")
    108 	strings.write_string(&b, f.rule)
    109 	strings.write_string(&b, "]")
    110 	return strings.to_string(b)
    111 }
    112 
    113 // sort orders findings so the ones worth reading first are first.
    114 sort :: proc(findings: []Finding) {
    115 	slice.stable_sort_by_cmp(findings, proc(a, b: Finding) -> slice.Ordering {
    116 		if a.severity != b.severity {
    117 			return .Less if a.severity < b.severity else .Greater
    118 		}
    119 		if a.file != b.file {
    120 			return .Less if a.file < b.file else .Greater
    121 		}
    122 		if a.line != b.line {
    123 			return .Less if a.line < b.line else .Greater
    124 		}
    125 		return .Equal
    126 	})
    127 }
    128 
    129 // must_fix reports whether any finding is one worth blocking on.
    130 must_fix :: proc(findings: []Finding) -> bool {
    131 	for f in findings {
    132 		if f.severity == .Must_Fix {
    133 			return true
    134 		}
    135 	}
    136 	return false
    137 }
    138 
    139 // identify gives a finding its short id: a hash of what it is about, not
    140 // where it sits or how it was worded. The line is left out because lines
    141 // move under edits that do not touch the finding; a model's message is
    142 // left out because a fresh reading may word it differently. A finding
    143 // with no symbol is told from its neighbours by its line, and a
    144 // deterministic check's message is part of what it is about and stable,
    145 // so it stays in. The hash is the Go tool's, byte for byte, so both
    146 // readings name a finding the same.
    147 identify :: proc(f: ^Finding, allocator := context.allocator) {
    148 	parts := make([dynamic]string, context.temp_allocator)
    149 	append(&parts, f.job, f.rule, f.file, f.symbol)
    150 	switch {
    151 	case f.job == "static":
    152 		append(&parts, f.message)
    153 	case f.symbol == "":
    154 		append(&parts, fmt.tprintf("%d", f.line))
    155 	}
    156 	joined := strings.join(parts[:], "\x00", context.temp_allocator)
    157 	ctx: sha2.Context_256
    158 	sha2.init_256(&ctx)
    159 	sha2.update(&ctx, transmute([]byte)joined)
    160 	digest: [32]byte
    161 	sha2.final(&ctx, digest[:])
    162 	encoded := hex.encode(digest[:], context.temp_allocator)
    163 	f.id = strings.clone(string(encoded[:12]), allocator)
    164 }
    165 
    166 // name fills in what a finding carries only in the report: its severity
    167 // as text, and the id that names it across runs. Two findings that hash
    168 // the same — one rule, one file, two comments — are told apart by a
    169 // counter, in the order they are listed.
    170 name :: proc(findings: []Finding, allocator := context.allocator) {
    171 	seen := make(map[string]int, context.temp_allocator)
    172 	for &f in findings {
    173 		f.severity_name = severity_name(f.severity)
    174 		identify(&f, allocator)
    175 		seen[f.id] += 1
    176 		if n := seen[f.id]; n > 1 {
    177 			f.id = fmt.aprintf("%s-%d", f.id, n, allocator = allocator)
    178 		}
    179 	}
    180 }
    181 
    182 // suppressed reports whether the source dismisses a finding, and with what
    183 // reason. The dismissal lives in the source beside what it justifies:
    184 //
    185 //	//review:ignore <rule> <why>
    186 //
    187 // One on or just above the line it concerns covers that line; one anywhere
    188 // in a file covers the findings that name no line.
    189 suppressed :: proc(
    190 	f: Finding,
    191 	root: string,
    192 	allocator := context.allocator,
    193 ) -> (
    194 	why: string,
    195 	ok: bool,
    196 ) {
    197 	if f.file == "" {
    198 		return "", false
    199 	}
    200 	path := f.file
    201 	if root != "" {
    202 		path = filepath.join({root, f.file}, context.temp_allocator) or_else f.file
    203 	}
    204 	source, err := os.read_entire_file_from_path(path, context.temp_allocator)
    205 	if err != nil {
    206 		return "", false
    207 	}
    208 	text_left := string(source)
    209 	line := 0
    210 	for text in strings.split_lines_iterator(&text_left) {
    211 		line += 1
    212 		rule, reason, found := dismissal(text)
    213 		if !found || !rules_match(rule, f.rule) {
    214 			continue
    215 		}
    216 		if reason == "" {
    217 			reason = "no reason given"
    218 		}
    219 		// A dismissal with no line to answer to covers the file; otherwise
    220 		// it has to sit within a few lines of what it dismisses, so that
    221 		// moving code does not carry a dismissal somewhere it was never
    222 		// meant.
    223 		if f.line == 0 || (line >= f.line - 3 && line <= f.line + 1) {
    224 			return strings.clone(reason, allocator), true
    225 		}
    226 	}
    227 	return "", false
    228 }
    229 
    230 // dismissal reads the rule and reason out of a line holding a dismissal:
    231 // two slashes, review:ignore, the rule, and what follows.
    232 dismissal :: proc(text: string) -> (rule, why: string, found: bool) {
    233 	marker :: "review:ignore"
    234 	at := strings.index(text, marker)
    235 	if at < 0 {
    236 		return
    237 	}
    238 	before := strings.trim_right_space(text[:at])
    239 	if !strings.has_suffix(before, "//") {
    240 		return
    241 	}
    242 	rest := strings.trim_left_space(text[at + len(marker):])
    243 	if len(rest) == len(text[at + len(marker):]) {
    244 		return // The marker is not followed by whitespace.
    245 	}
    246 	end := strings.index_any(rest, " \t")
    247 	if end < 0 {
    248 		return rest, "", len(rest) > 0
    249 	}
    250 	return rest[:end], strings.trim_space(rest[end:]), true
    251 }
    252 
    253 // rules_match compares a dismissal against a rule, where "all" dismisses
    254 // anything the job found at that spot.
    255 rules_match :: proc(dismissed, rule: string) -> bool {
    256 	return dismissed == "all" || strings.equal_fold(dismissed, rule)
    257 }
    258 
    259 // atoi reads a line number a job reported, which may arrive as a string.
    260 atoi :: proc(s: string) -> int {
    261 	n, ok := strconv.parse_int(strings.trim_space(s))
    262 	return n if ok else 0
    263 }