report.odin (10162B)
1 /* 2 Package report is the whole measurement a program reads: what was found, 3 what the second reading did not let stand, what was dismissed in the 4 source, and how complete the measurement was. The status is what makes an 5 empty findings list readable: "complete" is the only status an empty list 6 can be read as a pass against, because the others say which part of the 7 measurement is missing. The JSON is the Go tool's, key for key. 8 */ 9 package report 10 11 import "core:encoding/json" 12 import "core:fmt" 13 import "core:os" 14 import "core:slice" 15 import "core:strings" 16 17 import "../change" 18 import "../finding" 19 20 version :: 1 21 22 // Job_Fault is one job that could not finish, and what failed about it. 23 Job_Fault :: struct { 24 job: string `json:"job"`, 25 error: string `json:"error"`, 26 } 27 28 // Metered is what the asks cost, in tokens and dollars. Answers replayed 29 // from the cache count as replayed rather than as usage. 30 Metered :: struct { 31 tokens_in: int `json:"in"`, 32 tokens_out: int `json:"out"`, 33 cached: int `json:"cached"`, 34 replayed: int `json:"replayed"`, 35 cost: f64 `json:"usd"`, 36 } 37 38 // Retracted is a finding the second reading did not let stand, and why. 39 Retracted :: struct { 40 finding: finding.Finding `json:"finding"`, 41 reason: string `json:"reason"`, 42 } 43 44 // Dismissed is a finding the source itself answered, and the answer. 45 Dismissed :: struct { 46 finding: finding.Finding `json:"finding"`, 47 why: string `json:"why"`, 48 } 49 50 // Baseline is this run's findings named against a previous report's, by 51 // id. A loop reads resolved to know its fixes took, persisting to know 52 // what is left, and new to know what the fixes cost. 53 Baseline :: struct { 54 from: string `json:"from"`, 55 resolved: []string `json:"resolved"`, 56 persisting: []string `json:"persisting"`, 57 new: []string `json:"new"`, 58 } 59 60 Contract :: struct { 61 version: int `json:"version"`, 62 status: string `json:"status"`, 63 provider: string `json:"provider,omitempty"`, 64 findings: []finding.Finding `json:"findings"`, 65 retracted: []Retracted `json:"retracted"`, 66 failed: []Job_Fault `json:"failed"`, 67 skipped: []string `json:"skipped"`, 68 uncovered: []change.Gap `json:"uncovered"`, 69 dismissed: []Dismissed `json:"dismissed"`, 70 truncated: bool `json:"truncated,omitempty"`, 71 baseline: Maybe(Baseline) `json:"baseline,omitempty"`, 72 usage: Metered `json:"usage"`, 73 } 74 75 // compare reads the findings of a previous report and names this run's 76 // against them, by id. Only the report's standing findings count: a 77 // finding it retracted or dismissed was not one to fix. 78 compare :: proc( 79 path: string, 80 now: []finding.Finding, 81 allocator := context.allocator, 82 ) -> ( 83 b: Baseline, 84 err: string, 85 ) { 86 data, read_err := os.read_entire_file_from_path(path, context.temp_allocator) 87 if read_err != nil { 88 return b, fmt.aprintf("reading the baseline: %s", path, allocator = allocator) 89 } 90 Previous :: struct { 91 findings: []struct { 92 id: string `json:"id"`, 93 } `json:"findings"`, 94 } 95 previous: Previous 96 if json.unmarshal(data, &previous, allocator = context.temp_allocator) != nil { 97 return b, "the baseline is not a review report" 98 } 99 before := make(map[string]bool, context.temp_allocator) 100 for f in previous.findings { 101 if f.id != "" { 102 before[f.id] = true 103 } 104 } 105 b.from = strings.clone(path, allocator) 106 resolved := make([dynamic]string, allocator) 107 persisting := make([dynamic]string, allocator) 108 fresh := make([dynamic]string, allocator) 109 seen := make(map[string]bool, context.temp_allocator) 110 for f in now { 111 seen[f.id] = true 112 if before[f.id] { 113 append(&persisting, strings.clone(f.id, allocator)) 114 } else { 115 append(&fresh, strings.clone(f.id, allocator)) 116 } 117 } 118 for id in before { 119 if !seen[id] { 120 append(&resolved, strings.clone(id, allocator)) 121 } 122 } 123 slice.sort(resolved[:]) 124 b.resolved, b.persisting, b.new = resolved[:], persisting[:], fresh[:] 125 return b, "" 126 } 127 128 // status_of is how complete the measurement was: a job that failed, a 129 // file no reader covered, or a diff cut short each leave a hole. 130 status_of :: proc(c: change.Change, failures: int) -> string { 131 if failures > 0 || len(c.uncovered) > 0 || c.truncated { 132 return "incomplete" 133 } 134 return "complete" 135 } 136 137 // filter drops the findings the source itself dismisses, and keeps them 138 // so that a silent dismissal can still be read back. 139 filter :: proc( 140 root: string, 141 findings: []finding.Finding, 142 allocator := context.allocator, 143 ) -> ( 144 kept: []finding.Finding, 145 dismissed: []Dismissed, 146 ) { 147 keep := make([dynamic]finding.Finding, allocator) 148 drop := make([dynamic]Dismissed, allocator) 149 for f in findings { 150 if why, ok := finding.suppressed(f, root, allocator); ok { 151 append(&drop, Dismissed{f, why}) 152 continue 153 } 154 append(&keep, f) 155 } 156 return keep[:], drop[:] 157 } 158 159 // render is the report as a person reads it, grouped by severity, the 160 // serious first. A dismissal reaches a few lines either side of itself, 161 // so which findings one answered is worth being able to read when asked; 162 // a retraction is the second reading's word against the first's. 163 render :: proc(env: Contract, verbose := false, allocator := context.allocator) -> string { 164 b := strings.builder_make(allocator) 165 if verbose { 166 for d in env.dismissed { 167 fmt.sbprintf( 168 &b, 169 " dismissed: %s:%d %s (%s)\n", 170 d.finding.file, 171 d.finding.line, 172 d.finding.rule, 173 d.why, 174 ) 175 } 176 for r in env.retracted { 177 fmt.sbprintf( 178 &b, 179 " retracted: [%s] %s:%d — %s\n", 180 r.finding.rule, 181 r.finding.file, 182 r.finding.line, 183 first(r.reason, 120), 184 ) 185 } 186 } 187 if len(env.findings) == 0 { 188 strings.write_string(&b, "no findings") 189 if len(env.dismissed) > 0 { 190 fmt.sbprintf(&b, " (%d dismissed in the source)", len(env.dismissed)) 191 } 192 strings.write_string(&b, "\n") 193 notes(&b, env) 194 return strings.to_string(b) 195 } 196 announced := false 197 severity: finding.Severity 198 for f in env.findings { 199 if !announced || f.severity != severity { 200 severity = f.severity 201 announced = true 202 fmt.sbprintf( 203 &b, 204 "\n%s\n", 205 strings.to_upper(finding.severity_name(severity), context.temp_allocator), 206 ) 207 } 208 line := finding.to_string(f, context.temp_allocator) 209 indented, _ := strings.replace_all(line, "\n ", "\n ", context.temp_allocator) 210 fmt.sbprintf(&b, " %s\n", indented) 211 } 212 fmt.sbprintf(&b, "\n%d findings", len(env.findings)) 213 if len(env.dismissed) > 0 { 214 fmt.sbprintf(&b, ", %d dismissed in the source", len(env.dismissed)) 215 } 216 strings.write_string(&b, "\n") 217 notes(&b, env) 218 if verbose { 219 strings.write_string( 220 &b, 221 "\nDismiss a finding where it is wrong, in the source it concerns:\n //review:ignore <rule> <why>\n", 222 ) 223 } 224 return strings.to_string(b) 225 } 226 227 // notes says what a reader would otherwise miss: findings that did not 228 // survive verification, and how the run stands against a baseline. 229 notes :: proc(b: ^strings.Builder, env: Contract) { 230 if len(env.retracted) > 0 { 231 fmt.sbprintf( 232 b, 233 "%d of the findings reported did not survive verification\n", 234 len(env.retracted), 235 ) 236 } 237 if base, given := env.baseline.?; given { 238 fmt.sbprintf( 239 b, 240 "against %s: %d resolved, %d persisting, %d new\n", 241 base.from, 242 len(base.resolved), 243 len(base.persisting), 244 len(base.new), 245 ) 246 if len(base.persisting) > 0 { 247 fmt.sbprintf( 248 b, 249 " persisting: %s\n", 250 strings.join(base.persisting, ", ", context.temp_allocator), 251 ) 252 } 253 } 254 } 255 256 // encode is the contract as JSON. Every list is said even when empty: a 257 // key an agent cannot find is a hole it guesses about. Each finding is 258 // given its stable id and its severity as text, which is what a loop 259 // needs to answer a finding and check it stayed answered. 260 encode :: proc(env: Contract, allocator := context.allocator) -> (out: string, ok: bool) { 261 named := env 262 named.version = version 263 named.findings = present(env.findings, context.temp_allocator) 264 named.retracted = present(env.retracted, context.temp_allocator) 265 named.failed = present(env.failed, context.temp_allocator) 266 named.skipped = present(env.skipped, context.temp_allocator) 267 named.uncovered = present(env.uncovered, context.temp_allocator) 268 named.dismissed = present(env.dismissed, context.temp_allocator) 269 finding.name(named.findings, context.temp_allocator) 270 retracted := make([]finding.Finding, len(named.retracted), context.temp_allocator) 271 for r, i in named.retracted { 272 retracted[i] = r.finding 273 } 274 finding.name(retracted, context.temp_allocator) 275 for &r, i in named.retracted { 276 r.finding = retracted[i] 277 } 278 dismissed := make([]finding.Finding, len(named.dismissed), context.temp_allocator) 279 for d, i in named.dismissed { 280 dismissed[i] = d.finding 281 } 282 finding.name(dismissed, context.temp_allocator) 283 for &d, i in named.dismissed { 284 d.finding = dismissed[i] 285 } 286 data, err := json.marshal( 287 named, 288 {pretty = true, use_spaces = true, spaces = 2}, 289 context.temp_allocator, 290 ) 291 if err != nil { 292 return "", false 293 } 294 return compact(string(data), allocator), true 295 } 296 297 // compact writes an empty list as [] on one line, where the marshaller 298 // leaves a blank line inside it, and leaves out a truncation that did not 299 // happen, as the Go tool does. 300 compact :: proc(text: string, allocator := context.allocator) -> string { 301 out, _ := strings.replace_all(text, "\n \"truncated\": false,", "", context.temp_allocator) 302 for depth in 0 ..< 8 { 303 indent := strings.repeat(" ", depth, context.temp_allocator) 304 pattern := strings.concatenate({"[\n\n", indent, "]"}, context.temp_allocator) 305 out, _ = strings.replace_all(out, pattern, "[]", context.temp_allocator) 306 } 307 return strings.clone(out, allocator) 308 } 309 310 // present is a slice that is never nil, so that it is written as [] rather 311 // than left out or written as null. 312 present :: proc(items: []$T, allocator := context.allocator) -> []T { 313 if items == nil { 314 return make([]T, 0, allocator) 315 } 316 copied := make([]T, len(items), allocator) 317 copy(copied, items) 318 return copied 319 } 320 321 // first is the start of a string, with an ellipsis where it was cut. 322 first :: proc(s: string, n: int, allocator := context.allocator) -> string { 323 trimmed := strings.trim_space(s) 324 if len(trimmed) > n { 325 return strings.concatenate({trimmed[:n], "…"}, allocator) 326 } 327 return trimmed 328 }