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 }