review

review patchsets using your default editor
Log | Files | Refs

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 }