commit 034e4d8dbf495159a775083b4411e2376dfb46c5
parent 027eec57b4a25b7a8a2d4399ddb898094d93b609
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 19:50:49 -0300
odin: report findings through the Go tool's contract
finding is one thing a reading noticed, sorted serious first, named by
the same sha256 the Go tool names it by so both readings agree on an id,
and dismissed by the same //review:ignore comment beside the line.
report is the JSON contract key for key — status, findings, retracted,
failed, skipped, uncovered, dismissed, usage — and the rendering a
person reads. The driver reports through it, and says which files no
reader covered, with the reason each.
Diffstat:
8 files changed, 1063 insertions(+), 30 deletions(-)
diff --git a/justfile b/justfile
@@ -41,7 +41,7 @@ odin-test:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p build
- for p in frontend git tree change; do
+ for p in frontend git tree change finding report; do
{{odin}} test odin/$p {{odin_flags}} -out:build/${p}_test
done
diff --git a/odin/change/change.odin b/odin/change/change.odin
@@ -7,6 +7,8 @@ what new work is judged against.
*/
package change
+import "core:fmt"
+import "core:path/filepath"
import "core:strconv"
import "core:strings"
@@ -53,6 +55,12 @@ Declared :: struct {
body: string,
}
+// Gap is a file the deterministic side could not read, and why.
+Gap :: struct {
+ file: string `json:"file"`,
+ reason: string `json:"reason"`,
+}
+
// Diff_Line is one added line and the number it lands on.
Diff_Line :: struct {
line: int,
@@ -77,8 +85,8 @@ Change :: struct {
tests: [dynamic]Function,
comments: [dynamic]Located,
imports: map[string][]string,
- // uncovered are the code files no sidecar reads.
- uncovered: [dynamic]string,
+ // uncovered are the code files no sidecar read, with a reason each.
+ uncovered: [dynamic]Gap,
}
max_diff :: 60000
@@ -202,6 +210,16 @@ side :: proc(path, prefix: string) -> string {
read :: proc(c: ^Change, t: tree.Tree, allocator := context.allocator) -> bool {
context.allocator = allocator
ok := true
+ for name in c.files {
+ // A file no reader could plausibly exist for — prose, data,
+ // configuration — is not a promise the review broke; a code file
+ // with no reader is, because a job that would have read it read
+ // nothing.
+ if _, covered := frontend.sidecar_for(name);
+ !covered && is_code_file(name) && tree.exists(t, name) {
+ append(&c.uncovered, Gap{name, "no reader"})
+ }
+ }
for sidecar in frontend.Sidecar {
names := make([dynamic]string, context.temp_allocator)
for name in c.files {
@@ -215,14 +233,21 @@ read :: proc(c: ^Change, t: tree.Tree, allocator := context.allocator) -> bool {
}
answers, err := scan(sidecar, t, names[:], context.temp_allocator)
if err != .None {
- append(&c.uncovered, ..names[:])
+ why := fmt.aprintf("%s: %s", frontend.binary(sidecar), scan_error(err))
+ for name in names {
+ append(&c.uncovered, Gap{name, why})
+ }
ok = false
continue
}
for name in names {
file, answered := answers[name]
- if !answered || file.error != "" {
- append(&c.uncovered, name)
+ if !answered {
+ append(&c.uncovered, Gap{name, "no answer from the sidecar"})
+ continue
+ }
+ if file.error != "" {
+ append(&c.uncovered, Gap{name, strings.clone(file.error)})
continue
}
read_file(c, t, name, file)
@@ -373,6 +398,44 @@ scan :: proc(
return answers, .None
}
+// scan_error says what a sidecar failure was.
+scan_error :: proc(err: frontend.Scan_Error) -> string {
+ switch err {
+ case .None:
+ return ""
+ case .Not_Installed:
+ return "not on the path"
+ case .Failed:
+ return "gave no answer"
+ case .Unreadable:
+ return "gave an answer that is not its JSON"
+ }
+ return ""
+}
+
+// is_code_file reports a file a reader could plausibly exist for: not
+// prose, data, configuration or the files a repository keeps for git.
+is_code_file :: proc(path: string) -> bool {
+ base := filepath.base(path)
+ switch base {
+ case ".gitignore",
+ ".gitattributes",
+ ".gitmodules",
+ ".editorconfig",
+ "Makefile",
+ "Dockerfile",
+ "LICENSE",
+ "CODEOWNERS":
+ return false
+ }
+ for ext in ([]string{".md", ".mdx", ".txt", ".rst", ".adoc", ".json", ".yaml", ".yml", ".toml", ".lock", ".sum", ".mod", ".html", ".htm", ".css", ".svg", ".xml"}) {
+ if strings.has_suffix(base, ext) {
+ return false
+ }
+ }
+ return true
+}
+
// is_test_file reports a file the test runner reads, in Go's habit.
is_test_file :: proc(name: string) -> bool {
return strings.has_suffix(name, "_test.go")
diff --git a/odin/change/change_test.odin b/odin/change/change_test.odin
@@ -140,6 +140,16 @@ gather_reads_a_range_through_the_sidecar :: proc(t: ^testing.T) {
}
@(test)
+code_files_are_the_ones_a_reader_could_exist_for :: proc(t: ^testing.T) {
+ testing.expect(t, is_code_file("a/b.zig"))
+ testing.expect(t, is_code_file("Justfile"))
+ testing.expect(t, !is_code_file("readme.md"))
+ testing.expect(t, !is_code_file("go.sum"))
+ testing.expect(t, !is_code_file("x/.gitignore"))
+ testing.expect(t, !is_code_file("Dockerfile"))
+}
+
+@(test)
gather_of_nothing_staged_is_empty :: proc(t: ^testing.T) {
root, made := new_repo(t)
testing.expect(t, made, "the fixture repository")
diff --git a/odin/finding/finding.odin b/odin/finding/finding.odin
@@ -0,0 +1,263 @@
+/*
+Package finding is one thing a reading noticed: what it concerns, how
+seriously to take it, and the stable name a loop refers to it by. It also
+reads the dismissal a source file carries beside what it justifies.
+*/
+package finding
+
+import "core:crypto/sha2"
+import "core:encoding/hex"
+import "core:fmt"
+import "core:os"
+import "core:path/filepath"
+import "core:slice"
+import "core:strconv"
+import "core:strings"
+
+// Severity is what a finding means for the change, most serious first.
+// Only Must_Fix is worth blocking on; the rest are reported and left to
+// judgement, because a loop that treats taste as an error never finishes.
+Severity :: enum {
+ Must_Fix,
+ Consider,
+ Note,
+}
+
+severity_name :: proc(s: Severity) -> string {
+ switch s {
+ case .Must_Fix:
+ return "must-fix"
+ case .Consider:
+ return "consider"
+ case .Note:
+ return "note"
+ }
+ return "unknown"
+}
+
+// parse_severity reads a severity the way a job reports it, defaulting to
+// the least serious rather than failing: a job that invents a word should
+// not stop the run.
+parse_severity :: proc(s: string) -> Severity {
+ switch strings.to_lower(strings.trim_space(s), context.temp_allocator) {
+ case "must-fix", "must fix", "mustfix", "error":
+ return .Must_Fix
+ case "consider", "warning":
+ return .Consider
+ }
+ return .Note
+}
+
+// Finding is one thing a job noticed. The JSON names are the contract's,
+// shared with the Go tool, so a loop reads either report the same way.
+Finding :: struct {
+ // job is the job that reported it; rule the criterion it was judged
+ // against. A finding that cites no rule is dropped, so that the
+ // criteria are what gets tuned rather than the prompt.
+ job: string `json:"job"`,
+ rule: string `json:"rule"`,
+ severity: Severity `json:"-"`,
+ // severity_name is how the severity travels in and out of JSON.
+ severity_name: string `json:"severity"`,
+ // file and line locate it, where it has a location; symbol names what
+ // it concerns, for the findings that are about a name rather than a
+ // place.
+ file: string `json:"file,omitempty"`,
+ line: int `json:"line,omitempty"`,
+ symbol: string `json:"symbol,omitempty"`,
+ message: string `json:"message"`,
+ // fix is the concrete change suggested, which is what lets an agent
+ // act on the finding rather than reason about it again; snippet is the
+ // line the finding points at, as it stands.
+ fix: string `json:"fix,omitempty"`,
+ snippet: string `json:"snippet,omitempty"`,
+ // verified is what stands behind the finding: the deterministic checks
+ // verify themselves; a model's finding is verified when a second
+ // reading of the same evidence let it stand.
+ verified: bool `json:"verified"`,
+ // id is a stable short name: a hash of what the finding is about, not
+ // where it sits, so that a re-run names the same finding again.
+ id: string `json:"id,omitempty"`,
+ // part is which part of a split subject the finding came from.
+ part: int `json:"-"`,
+}
+
+// to_string is the finding as a person reads it.
+to_string :: proc(f: Finding, allocator := context.allocator) -> string {
+ b := strings.builder_make(allocator)
+ strings.write_string(&b, severity_name(f.severity))
+ strings.write_string(&b, ": ")
+ if f.file != "" {
+ strings.write_string(&b, f.file)
+ if f.line > 0 {
+ fmt.sbprintf(&b, ":%d", f.line)
+ }
+ strings.write_string(&b, ": ")
+ } else if f.symbol != "" {
+ strings.write_string(&b, f.symbol)
+ strings.write_string(&b, ": ")
+ }
+ strings.write_string(&b, f.message)
+ if f.fix != "" {
+ strings.write_string(&b, "\n → ")
+ strings.write_string(&b, f.fix)
+ }
+ strings.write_string(&b, "\n [")
+ strings.write_string(&b, f.job)
+ strings.write_string(&b, "/")
+ strings.write_string(&b, f.rule)
+ strings.write_string(&b, "]")
+ return strings.to_string(b)
+}
+
+// sort orders findings so the ones worth reading first are first.
+sort :: proc(findings: []Finding) {
+ slice.stable_sort_by_cmp(findings, proc(a, b: Finding) -> slice.Ordering {
+ if a.severity != b.severity {
+ return .Less if a.severity < b.severity else .Greater
+ }
+ if a.file != b.file {
+ return .Less if a.file < b.file else .Greater
+ }
+ if a.line != b.line {
+ return .Less if a.line < b.line else .Greater
+ }
+ return .Equal
+ })
+}
+
+// must_fix reports whether any finding is one worth blocking on.
+must_fix :: proc(findings: []Finding) -> bool {
+ for f in findings {
+ if f.severity == .Must_Fix {
+ return true
+ }
+ }
+ return false
+}
+
+// identify gives a finding its short id: a hash of what it is about, not
+// where it sits or how it was worded. The line is left out because lines
+// move under edits that do not touch the finding; a model's message is
+// left out because a fresh reading may word it differently. A finding
+// with no symbol is told from its neighbours by its line, and a
+// deterministic check's message is part of what it is about and stable,
+// so it stays in. The hash is the Go tool's, byte for byte, so both
+// readings name a finding the same.
+identify :: proc(f: ^Finding, allocator := context.allocator) {
+ parts := make([dynamic]string, context.temp_allocator)
+ append(&parts, f.job, f.rule, f.file, f.symbol)
+ switch {
+ case f.job == "static":
+ append(&parts, f.message)
+ case f.symbol == "":
+ append(&parts, fmt.tprintf("%d", f.line))
+ }
+ joined := strings.join(parts[:], "\x00", context.temp_allocator)
+ ctx: sha2.Context_256
+ sha2.init_256(&ctx)
+ sha2.update(&ctx, transmute([]byte)joined)
+ digest: [32]byte
+ sha2.final(&ctx, digest[:])
+ encoded := hex.encode(digest[:], context.temp_allocator)
+ f.id = strings.clone(string(encoded[:12]), allocator)
+}
+
+// name fills in what a finding carries only in the report: its severity
+// as text, and the id that names it across runs. Two findings that hash
+// the same — one rule, one file, two comments — are told apart by a
+// counter, in the order they are listed.
+name :: proc(findings: []Finding, allocator := context.allocator) {
+ seen := make(map[string]int, context.temp_allocator)
+ for &f in findings {
+ f.severity_name = severity_name(f.severity)
+ identify(&f, allocator)
+ seen[f.id] += 1
+ if n := seen[f.id]; n > 1 {
+ f.id = fmt.aprintf("%s-%d", f.id, n, allocator = allocator)
+ }
+ }
+}
+
+// suppressed reports whether the source dismisses a finding, and with what
+// reason. The dismissal lives in the source beside what it justifies:
+//
+// //review:ignore <rule> <why>
+//
+// One on or just above the line it concerns covers that line; one anywhere
+// in a file covers the findings that name no line.
+suppressed :: proc(
+ f: Finding,
+ root: string,
+ allocator := context.allocator,
+) -> (
+ why: string,
+ ok: bool,
+) {
+ if f.file == "" {
+ return "", false
+ }
+ path := f.file
+ if root != "" {
+ path = filepath.join({root, f.file}, context.temp_allocator) or_else f.file
+ }
+ source, err := os.read_entire_file_from_path(path, context.temp_allocator)
+ if err != nil {
+ return "", false
+ }
+ text_left := string(source)
+ line := 0
+ for text in strings.split_lines_iterator(&text_left) {
+ line += 1
+ rule, reason, found := dismissal(text)
+ if !found || !rules_match(rule, f.rule) {
+ continue
+ }
+ if reason == "" {
+ reason = "no reason given"
+ }
+ // A dismissal with no line to answer to covers the file; otherwise
+ // it has to sit within a few lines of what it dismisses, so that
+ // moving code does not carry a dismissal somewhere it was never
+ // meant.
+ if f.line == 0 || (line >= f.line - 3 && line <= f.line + 1) {
+ return strings.clone(reason, allocator), true
+ }
+ }
+ return "", false
+}
+
+// dismissal reads the rule and reason out of a line holding a dismissal:
+// two slashes, review:ignore, the rule, and what follows.
+dismissal :: proc(text: string) -> (rule, why: string, found: bool) {
+ marker :: "review:ignore"
+ at := strings.index(text, marker)
+ if at < 0 {
+ return
+ }
+ before := strings.trim_right_space(text[:at])
+ if !strings.has_suffix(before, "//") {
+ return
+ }
+ rest := strings.trim_left_space(text[at + len(marker):])
+ if len(rest) == len(text[at + len(marker):]) {
+ return // The marker is not followed by whitespace.
+ }
+ end := strings.index_any(rest, " \t")
+ if end < 0 {
+ return rest, "", len(rest) > 0
+ }
+ return rest[:end], strings.trim_space(rest[end:]), true
+}
+
+// rules_match compares a dismissal against a rule, where "all" dismisses
+// anything the job found at that spot.
+rules_match :: proc(dismissed, rule: string) -> bool {
+ return dismissed == "all" || strings.equal_fold(dismissed, rule)
+}
+
+// atoi reads a line number a job reported, which may arrive as a string.
+atoi :: proc(s: string) -> int {
+ n, ok := strconv.parse_int(strings.trim_space(s))
+ return n if ok else 0
+}
diff --git a/odin/finding/finding_test.odin b/odin/finding/finding_test.odin
@@ -0,0 +1,211 @@
+package finding
+
+import "core:os"
+import "core:path/filepath"
+import "core:strings"
+import "core:testing"
+
+@(test)
+severity_round_trips :: proc(t: ^testing.T) {
+ testing.expect_value(t, parse_severity(" Must-Fix "), Severity.Must_Fix)
+ testing.expect_value(t, parse_severity("error"), Severity.Must_Fix)
+ testing.expect_value(t, parse_severity("warning"), Severity.Consider)
+ testing.expect_value(t, parse_severity("whatever"), Severity.Note)
+ testing.expect_value(t, severity_name(.Consider), "consider")
+}
+
+@(test)
+to_string_says_where_and_what :: proc(t: ^testing.T) {
+ s := to_string(
+ Finding {
+ job = "tests",
+ rule = "cannot-fail",
+ severity = .Must_Fix,
+ file = "a_test.go",
+ line = 4,
+ message = "m",
+ fix = "do x",
+ },
+ context.temp_allocator,
+ )
+ testing.expect_value(
+ t,
+ s,
+ "must-fix: a_test.go:4: m\n → do x\n [tests/cannot-fail]",
+ )
+ s = to_string(
+ Finding {
+ job = "namer",
+ rule = "abbreviation",
+ severity = .Note,
+ symbol = "cfg",
+ message = "m",
+ },
+ context.temp_allocator,
+ )
+ testing.expect_value(t, s, "note: cfg: m\n [namer/abbreviation]")
+}
+
+@(test)
+sort_puts_the_serious_first :: proc(t: ^testing.T) {
+ findings := []Finding {
+ {severity = .Note, file = "b.go", line = 1},
+ {severity = .Must_Fix, file = "b.go", line = 9},
+ {severity = .Must_Fix, file = "a.go", line = 2},
+ {severity = .Must_Fix, file = "a.go", line = 1},
+ }
+ sort(findings)
+ testing.expect_value(t, findings[0].file, "a.go")
+ testing.expect_value(t, findings[0].line, 1)
+ testing.expect_value(t, findings[1].line, 2)
+ testing.expect_value(t, findings[2].line, 9)
+ testing.expect_value(t, findings[3].severity, Severity.Note)
+ testing.expect(t, must_fix(findings))
+ testing.expect(t, !must_fix(findings[3:]))
+}
+
+@(test)
+id_is_stable_and_matches_the_go_tool :: proc(t: ^testing.T) {
+ f := Finding {
+ job = "tests",
+ rule = "cannot-fail",
+ file = "x_test.go",
+ line = 4,
+ symbol = "TestX",
+ message = "m",
+ }
+ identify(&f, context.temp_allocator)
+ // sha256("tests\x00cannot-fail\x00x_test.go\x00TestX")[:12], as Go names it.
+ testing.expect_value(t, f.id, "52ef4321ce06")
+ moved := f
+ moved.line = 9
+ identify(&moved, context.temp_allocator)
+ testing.expect_value(t, moved.id, f.id)
+ reworded := f
+ reworded.message = "said another way"
+ identify(&reworded, context.temp_allocator)
+ testing.expect_value(t, reworded.id, f.id)
+ other := f
+ other.symbol = "TestY"
+ identify(&other, context.temp_allocator)
+ testing.expect(t, other.id != f.id, "two findings share one id")
+
+ a := Finding {
+ job = "claims",
+ rule = "unsupported-claim",
+ file = "x.go",
+ line = 4,
+ }
+ b := Finding {
+ job = "claims",
+ rule = "unsupported-claim",
+ file = "x.go",
+ line = 9,
+ }
+ identify(&a, context.temp_allocator)
+ identify(&b, context.temp_allocator)
+ testing.expect(t, a.id != b.id, "two comments share one id")
+
+ s1 := Finding {
+ job = "static",
+ rule = "history-coupled-file",
+ file = "a.go",
+ message = "ties a.go to b.go",
+ }
+ s2 := Finding {
+ job = "static",
+ rule = "history-coupled-file",
+ file = "a.go",
+ message = "ties a.go to c.go",
+ }
+ identify(&s1, context.temp_allocator)
+ identify(&s2, context.temp_allocator)
+ testing.expect(t, s1.id != s2.id, "two partners share one id")
+}
+
+@(test)
+name_tells_twins_apart :: proc(t: ^testing.T) {
+ findings := []Finding {
+ {job = "namer", rule = "abbreviation", file = "x.go", symbol = "cfg", severity = .Note},
+ {job = "namer", rule = "abbreviation", file = "x.go", symbol = "cfg", severity = .Note},
+ }
+ name(findings, context.temp_allocator)
+ testing.expect(t, findings[0].id != findings[1].id)
+ testing.expect(t, strings.has_suffix(findings[1].id, "-2"))
+ testing.expect_value(t, findings[0].severity_name, "note")
+}
+
+write_source :: proc(t: ^testing.T, name, src: string) -> (dir: string) {
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ scratch, err := os.make_directory_temp(temp, "review-finding-*", context.temp_allocator)
+ if err != nil {
+ testing.fail_now(t, "no scratch directory")
+ }
+ path := filepath.join({scratch, name}, context.temp_allocator) or_else name
+ testing.expect(t, os.write_entire_file(path, transmute([]byte)src) == nil)
+ return scratch
+}
+
+@(test)
+suppressed_reads_the_dismissal_beside_the_line :: proc(t: ^testing.T) {
+ dir := write_source(
+ t,
+ "x.go",
+ "package x\n\n// nothing here\nconst a = 1\n\n//review:" +
+ "ignore restates-a-fact the ico package owns the other one\nconst b = 6\n\nconst c = 7\n\nconst d = 8\n\nconst e = 9\n",
+ )
+ defer os.remove_all(dir)
+ Case :: struct {
+ name: string,
+ f: Finding,
+ want: bool,
+ why: string,
+ }
+ for c in ([]Case{{"just above", {file = "x.go", line = 7, rule = "restates-a-fact"}, true, "the ico package owns the other one"}, {"on the line", {file = "x.go", line = 6, rule = "restates-a-fact"}, true, "the ico package owns the other one"}, {"distant", {file = "x.go", line = 12, rule = "restates-a-fact"}, false, ""}, {"another rule", {file = "x.go", line = 7, rule = "already-named"}, false, ""}, {"no line", {file = "x.go", rule = "restates-a-fact"}, true, "the ico package owns the other one"}, {"no file", {rule = "restates-a-fact"}, false, ""}, {"missing file", {file = "nowhere.go", line = 1, rule = "restates-a-fact"}, false, ""}}) {
+ why, ok := suppressed(c.f, dir, context.temp_allocator)
+ testing.expectf(t, ok == c.want, "%s: got %v, want %v", c.name, ok, c.want)
+ if ok {
+ testing.expectf(t, why == c.why, "%s: reason %q", c.name, why)
+ }
+ }
+}
+
+@(test)
+suppressed_reads_all_and_no_reason :: proc(t: ^testing.T) {
+ dir := write_source(t, "x.go", "//review:" + "ignore all generated\nconst a = 1\n")
+ defer os.remove_all(dir)
+ why, ok := suppressed(
+ Finding{file = "x.go", line = 2, rule = "anything-at-all"},
+ dir,
+ context.temp_allocator,
+ )
+ testing.expect(t, ok)
+ testing.expect_value(t, why, "generated")
+ other := write_source(
+ t,
+ "y.go",
+ "// review:" + "ignore cannot-fail\nfunc TestX(t *testing.T) {}\n",
+ )
+ defer os.remove_all(other)
+ why, ok = suppressed(
+ Finding{file = "y.go", line = 2, rule = "cannot-fail"},
+ other,
+ context.temp_allocator,
+ )
+ testing.expect(t, ok, "a space after the slashes still dismisses")
+ testing.expect_value(t, why, "no reason given")
+}
+
+@(test)
+dismissal_needs_the_slashes :: proc(t: ^testing.T) {
+ rule, why, found := dismissal("x := 1 //review:" + "ignore no-shadow it is the loop's")
+ testing.expect(t, found)
+ testing.expect_value(t, rule, "no-shadow")
+ testing.expect_value(t, why, "it is the loop's")
+ _, _, found = dismissal("review:ignore no-shadow prose")
+ testing.expect(t, !found)
+ _, _, found = dismissal("//review:" + "ignore")
+ testing.expect(t, !found)
+ testing.expect_value(t, atoi(" 42 "), 42)
+ testing.expect_value(t, atoi("x"), 0)
+}
diff --git a/odin/report/report.odin b/odin/report/report.odin
@@ -0,0 +1,272 @@
+/*
+Package report is the whole measurement a program reads: what was found,
+what the second reading did not let stand, what was dismissed in the
+source, and how complete the measurement was. The status is what makes an
+empty findings list readable: "complete" is the only status an empty list
+can be read as a pass against, because the others say which part of the
+measurement is missing. The JSON is the Go tool's, key for key.
+*/
+package report
+
+import "core:encoding/json"
+import "core:fmt"
+import "core:strings"
+
+import "../change"
+import "../finding"
+
+version :: 1
+
+// Job_Fault is one job that could not finish, and what failed about it.
+Job_Fault :: struct {
+ job: string `json:"job"`,
+ error: string `json:"error"`,
+}
+
+// Metered is what the asks cost, in tokens and dollars. Answers replayed
+// from the cache count as replayed rather than as usage.
+Metered :: struct {
+ tokens_in: int `json:"in"`,
+ tokens_out: int `json:"out"`,
+ cached: int `json:"cached"`,
+ replayed: int `json:"replayed"`,
+ cost: f64 `json:"usd"`,
+}
+
+// Retracted is a finding the second reading did not let stand, and why.
+Retracted :: struct {
+ finding: finding.Finding `json:"finding"`,
+ reason: string `json:"reason"`,
+}
+
+// Dismissed is a finding the source itself answered, and the answer.
+Dismissed :: struct {
+ finding: finding.Finding `json:"finding"`,
+ why: string `json:"why"`,
+}
+
+// Baseline is this run's findings named against a previous report's, by
+// id. A loop reads resolved to know its fixes took, persisting to know
+// what is left, and new to know what the fixes cost.
+Baseline :: struct {
+ from: string `json:"from"`,
+ resolved: []string `json:"resolved"`,
+ persisting: []string `json:"persisting"`,
+ new: []string `json:"new"`,
+}
+
+Contract :: struct {
+ version: int `json:"version"`,
+ status: string `json:"status"`,
+ provider: string `json:"provider,omitempty"`,
+ findings: []finding.Finding `json:"findings"`,
+ retracted: []Retracted `json:"retracted"`,
+ failed: []Job_Fault `json:"failed"`,
+ skipped: []string `json:"skipped"`,
+ uncovered: []change.Gap `json:"uncovered"`,
+ dismissed: []Dismissed `json:"dismissed"`,
+ truncated: bool `json:"truncated,omitempty"`,
+ baseline: Maybe(Baseline) `json:"baseline,omitempty"`,
+ usage: Metered `json:"usage"`,
+}
+
+// status_of is how complete the measurement was: a job that failed, a
+// file no reader covered, or a diff cut short each leave a hole.
+status_of :: proc(c: change.Change, failures: int) -> string {
+ if failures > 0 || len(c.uncovered) > 0 || c.truncated {
+ return "incomplete"
+ }
+ return "complete"
+}
+
+// filter drops the findings the source itself dismisses, and keeps them
+// so that a silent dismissal can still be read back.
+filter :: proc(
+ root: string,
+ findings: []finding.Finding,
+ allocator := context.allocator,
+) -> (
+ kept: []finding.Finding,
+ dismissed: []Dismissed,
+) {
+ keep := make([dynamic]finding.Finding, allocator)
+ drop := make([dynamic]Dismissed, allocator)
+ for f in findings {
+ if why, ok := finding.suppressed(f, root, allocator); ok {
+ append(&drop, Dismissed{f, why})
+ continue
+ }
+ append(&keep, f)
+ }
+ return keep[:], drop[:]
+}
+
+// render is the report as a person reads it, grouped by severity, the
+// serious first. A dismissal reaches a few lines either side of itself,
+// so which findings one answered is worth being able to read when asked;
+// a retraction is the second reading's word against the first's.
+render :: proc(env: Contract, verbose := false, allocator := context.allocator) -> string {
+ b := strings.builder_make(allocator)
+ if verbose {
+ for d in env.dismissed {
+ fmt.sbprintf(
+ &b,
+ " dismissed: %s:%d %s (%s)\n",
+ d.finding.file,
+ d.finding.line,
+ d.finding.rule,
+ d.why,
+ )
+ }
+ for r in env.retracted {
+ fmt.sbprintf(
+ &b,
+ " retracted: [%s] %s:%d — %s\n",
+ r.finding.rule,
+ r.finding.file,
+ r.finding.line,
+ first(r.reason, 120),
+ )
+ }
+ }
+ if len(env.findings) == 0 {
+ strings.write_string(&b, "no findings")
+ if len(env.dismissed) > 0 {
+ fmt.sbprintf(&b, " (%d dismissed in the source)", len(env.dismissed))
+ }
+ strings.write_string(&b, "\n")
+ notes(&b, env)
+ return strings.to_string(b)
+ }
+ announced := false
+ severity: finding.Severity
+ for f in env.findings {
+ if !announced || f.severity != severity {
+ severity = f.severity
+ announced = true
+ fmt.sbprintf(
+ &b,
+ "\n%s\n",
+ strings.to_upper(finding.severity_name(severity), context.temp_allocator),
+ )
+ }
+ line := finding.to_string(f, context.temp_allocator)
+ indented, _ := strings.replace_all(line, "\n ", "\n ", context.temp_allocator)
+ fmt.sbprintf(&b, " %s\n", indented)
+ }
+ fmt.sbprintf(&b, "\n%d findings", len(env.findings))
+ if len(env.dismissed) > 0 {
+ fmt.sbprintf(&b, ", %d dismissed in the source", len(env.dismissed))
+ }
+ strings.write_string(&b, "\n")
+ notes(&b, env)
+ if verbose {
+ strings.write_string(
+ &b,
+ "\nDismiss a finding where it is wrong, in the source it concerns:\n //review:ignore <rule> <why>\n",
+ )
+ }
+ return strings.to_string(b)
+}
+
+// notes says what a reader would otherwise miss: findings that did not
+// survive verification, and how the run stands against a baseline.
+notes :: proc(b: ^strings.Builder, env: Contract) {
+ if len(env.retracted) > 0 {
+ fmt.sbprintf(
+ b,
+ "%d of the findings reported did not survive verification\n",
+ len(env.retracted),
+ )
+ }
+ if base, given := env.baseline.?; given {
+ fmt.sbprintf(
+ b,
+ "against %s: %d resolved, %d persisting, %d new\n",
+ base.from,
+ len(base.resolved),
+ len(base.persisting),
+ len(base.new),
+ )
+ if len(base.persisting) > 0 {
+ fmt.sbprintf(
+ b,
+ " persisting: %s\n",
+ strings.join(base.persisting, ", ", context.temp_allocator),
+ )
+ }
+ }
+}
+
+// encode is the contract as JSON. Every list is said even when empty: a
+// key an agent cannot find is a hole it guesses about. Each finding is
+// given its stable id and its severity as text, which is what a loop
+// needs to answer a finding and check it stayed answered.
+encode :: proc(env: Contract, allocator := context.allocator) -> (out: string, ok: bool) {
+ named := env
+ named.version = version
+ named.findings = present(env.findings, context.temp_allocator)
+ named.retracted = present(env.retracted, context.temp_allocator)
+ named.failed = present(env.failed, context.temp_allocator)
+ named.skipped = present(env.skipped, context.temp_allocator)
+ named.uncovered = present(env.uncovered, context.temp_allocator)
+ named.dismissed = present(env.dismissed, context.temp_allocator)
+ finding.name(named.findings, context.temp_allocator)
+ retracted := make([]finding.Finding, len(named.retracted), context.temp_allocator)
+ for r, i in named.retracted {
+ retracted[i] = r.finding
+ }
+ finding.name(retracted, context.temp_allocator)
+ for &r, i in named.retracted {
+ r.finding = retracted[i]
+ }
+ dismissed := make([]finding.Finding, len(named.dismissed), context.temp_allocator)
+ for d, i in named.dismissed {
+ dismissed[i] = d.finding
+ }
+ finding.name(dismissed, context.temp_allocator)
+ for &d, i in named.dismissed {
+ d.finding = dismissed[i]
+ }
+ data, err := json.marshal(
+ named,
+ {pretty = true, use_spaces = true, spaces = 2},
+ context.temp_allocator,
+ )
+ if err != nil {
+ return "", false
+ }
+ return compact(string(data), allocator), true
+}
+
+// compact writes an empty list as [] on one line, where the marshaller
+// leaves a blank line inside it.
+compact :: proc(text: string, allocator := context.allocator) -> string {
+ out := text
+ for depth in 0 ..< 8 {
+ indent := strings.repeat(" ", depth, context.temp_allocator)
+ pattern := strings.concatenate({"[\n\n", indent, "]"}, context.temp_allocator)
+ out, _ = strings.replace_all(out, pattern, "[]", context.temp_allocator)
+ }
+ return strings.clone(out, allocator)
+}
+
+// present is a slice that is never nil, so that it is written as [] rather
+// than left out or written as null.
+present :: proc(items: []$T, allocator := context.allocator) -> []T {
+ if items == nil {
+ return make([]T, 0, allocator)
+ }
+ copied := make([]T, len(items), allocator)
+ copy(copied, items)
+ return copied
+}
+
+// first is the start of a string, with an ellipsis where it was cut.
+first :: proc(s: string, n: int, allocator := context.allocator) -> string {
+ trimmed := strings.trim_space(s)
+ if len(trimmed) > n {
+ return strings.concatenate({trimmed[:n], "…"}, allocator)
+ }
+ return trimmed
+}
diff --git a/odin/report/report_test.odin b/odin/report/report_test.odin
@@ -0,0 +1,172 @@
+package report
+
+import "core:encoding/json"
+import "core:os"
+import "core:path/filepath"
+import "core:strings"
+import "core:testing"
+
+import "../change"
+import "../finding"
+
+@(test)
+render_groups_by_severity :: proc(t: ^testing.T) {
+ out := render(
+ Contract {
+ findings = {
+ {
+ job = "tests",
+ rule = "cannot-fail",
+ severity = .Must_Fix,
+ file = "a_test.go",
+ line = 4,
+ message = "one",
+ },
+ {
+ job = "namer",
+ rule = "abbreviation",
+ severity = .Note,
+ symbol = "cfg",
+ message = "two",
+ },
+ },
+ dismissed = {{}, {}},
+ },
+ allocator = context.temp_allocator,
+ )
+ for want in ([]string{"MUST-FIX", "NOTE", "one", "two", "2 findings", "2 dismissed in the source"}) {
+ testing.expectf(t, strings.contains(out, want), "%q missing from:\n%s", want, out)
+ }
+ testing.expect_value(t, strings.count(out, "MUST-FIX"), 1)
+}
+
+@(test)
+render_nothing :: proc(t: ^testing.T) {
+ testing.expect_value(
+ t,
+ strings.trim_space(render(Contract{}, allocator = context.temp_allocator)),
+ "no findings",
+ )
+ out := render(
+ Contract{dismissed = {{}, {}, {}}, retracted = {{}}},
+ allocator = context.temp_allocator,
+ )
+ testing.expect(t, strings.contains(out, "3 dismissed in the source"))
+ testing.expect(
+ t,
+ strings.contains(out, "1 of the findings reported did not survive verification"),
+ )
+ out = render(
+ Contract{baseline = Baseline{from = "old.json", persisting = {"abc"}}},
+ allocator = context.temp_allocator,
+ )
+ testing.expect(t, strings.contains(out, "against old.json: 0 resolved, 1 persisting, 0 new"))
+ testing.expect(t, strings.contains(out, "persisting: abc"))
+}
+
+@(test)
+encode_carries_the_contract :: proc(t: ^testing.T) {
+ out, ok := encode(
+ Contract {
+ status = "incomplete",
+ provider = "api/probe",
+ findings = {
+ {
+ job = "static",
+ rule = "test-deleted",
+ severity = .Must_Fix,
+ file = "x_test.go",
+ line = 3,
+ message = "m",
+ verified = true,
+ },
+ },
+ retracted = {
+ {
+ finding = {
+ job = "tests",
+ rule = "cannot-fail",
+ severity = .Must_Fix,
+ file = "x_test.go",
+ message = "m",
+ },
+ reason = "the test can fail",
+ },
+ },
+ failed = {{job = "duplication", error = "context canceled"}},
+ skipped = {"hygiene"},
+ uncovered = {{file = "a.odin", reason = "no tests parser"}},
+ dismissed = {{finding = {rule = "cannot-fail"}, why = "wrong"}},
+ truncated = true,
+ usage = {tokens_in = 10, tokens_out = 2, cached = 1, replayed = 3, cost = 0.5},
+ },
+ context.temp_allocator,
+ )
+ testing.expect(t, ok, "encode")
+ for want in ([]string{`"version": 1`, `"status": "incomplete"`, `"provider": "api/probe"`, `"severity": "must-fix"`, `"verified": true`, `"id": "`, `"reason": "the test can fail"`, `"error": "context canceled"`, `"skipped": [`, `"a.odin"`, `"why": "wrong"`, `"truncated": true`, `"usd": 0.5`}) {
+ testing.expectf(t, strings.contains(out, want), "%s missing from:\n%s", want, out)
+ }
+ testing.expect(t, !strings.contains(out, `"baseline"`), "an absent baseline is left out")
+ testing.expect(t, !strings.contains(out, `"part"`), "part is the reading's own")
+
+ // Every list is present even when empty, and the JSON parses.
+ empty, empty_ok := encode(Contract{status = "complete"}, context.temp_allocator)
+ testing.expect(t, empty_ok)
+ for key in ([]string{"findings", "retracted", "failed", "skipped", "uncovered", "dismissed"}) {
+ testing.expectf(t, strings.contains(empty, key), "%s missing from:\n%s", key, empty)
+ }
+ parsed, parse_err := json.parse_string(empty, allocator = context.temp_allocator)
+ testing.expect(t, parse_err == nil, "the JSON parses")
+ root := parsed.(json.Object)
+ testing.expect_value(t, len(root["findings"].(json.Array)), 0)
+ testing.expect(t, !strings.contains(empty, "null"), "no list is null")
+ testing.expect(t, strings.contains(empty, `"findings": []`), "an empty list is one line")
+}
+
+@(test)
+filter_drops_what_the_source_dismisses :: proc(t: ^testing.T) {
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ root, err := os.make_directory_temp(temp, "review-report-*", context.temp_allocator)
+ testing.expect(t, err == nil)
+ defer os.remove_all(root)
+ path := filepath.join({root, "x.go"}, context.temp_allocator) or_else ""
+ testing.expect(
+ t,
+ os.write_entire_file(
+ path,
+ transmute([]byte)string(
+ "package x\n\n//review:" +
+ "ignore restates-a-fact the ico package owns it\nconst b = 6\n",
+ ),
+ ) ==
+ nil,
+ )
+
+ kept, dismissed := filter(
+ root,
+ {
+ {file = "x.go", line = 4, rule = "restates-a-fact", message = "dismissed"},
+ {file = "x.go", line = 4, rule = "already-named", message = "kept"},
+ },
+ context.temp_allocator,
+ )
+ testing.expect_value(t, len(dismissed), 1)
+ testing.expect_value(t, dismissed[0].why, "the ico package owns it")
+ testing.expect_value(t, len(kept), 1)
+ testing.expect_value(t, kept[0].message, "kept")
+}
+
+@(test)
+status_says_where_the_hole_is :: proc(t: ^testing.T) {
+ c: change.Change
+ c.uncovered = make([dynamic]change.Gap, context.temp_allocator)
+ testing.expect_value(t, status_of(c, 0), "complete")
+ testing.expect_value(t, status_of(c, 1), "incomplete")
+ c.truncated = true
+ testing.expect_value(t, status_of(c, 0), "incomplete")
+ c.truncated = false
+ append(&c.uncovered, change.Gap{"a.zig", "no reader"})
+ testing.expect_value(t, status_of(c, 0), "incomplete")
+ f := finding.Finding{}
+ testing.expect_value(t, f.severity, finding.Severity.Must_Fix)
+}
diff --git a/odin/review/main.odin b/odin/review/main.odin
@@ -1,20 +1,37 @@
// review, the Odin reading: what a change adds, read through the language
-// sidecars, and the repository's declarations it is judged against. The
-// change is the staged one, or the revision range given.
+// sidecars, judged, and reported. The change is the staged one, or the
+// revision range given.
//
-// review # the staged change
-// review HEAD^..HEAD
+// review [--json] [--verbose] [--show] [rev]
package main
import "core:fmt"
import "core:os"
import "../change"
+import "../finding"
import "../git"
+import "../report"
import "../tree"
main :: proc() {
- rev := os.args[1] if len(os.args) > 1 else ""
+ as_json, verbose, show: bool
+ rev := ""
+ for arg in os.args[1:] {
+ switch arg {
+ case "--json":
+ as_json = true
+ case "--verbose":
+ verbose = true
+ case "--show":
+ show = true
+ case "-h", "--help":
+ fmt.println("usage: review [--json] [--verbose] [--show] [rev]")
+ return
+ case:
+ rev = arg
+ }
+ }
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil {
fmt.eprintln("review: no working directory")
@@ -31,7 +48,12 @@ main :: proc() {
os.exit(1)
}
if len(c.files) == 0 {
- fmt.println("nothing to review")
+ if as_json {
+ out, _ := report.encode(report.Contract{status = "empty"})
+ fmt.println(out)
+ } else {
+ fmt.println("nothing to review")
+ }
return
}
t, at_ok := tree.at(root, rev)
@@ -41,8 +63,43 @@ main :: proc() {
}
defer tree.close(t)
- read_ok := change.read(&c, t)
- fmt.printfln("=== change %s: %d files, %d added lines ===", rev, len(c.files), added(c))
+ change.read(&c, t)
+ if show {
+ list(c, t)
+ }
+ findings: [dynamic]finding.Finding
+ finding.sort(findings[:])
+ kept, dismissed := report.filter(t.dir, findings[:])
+ env := report.Contract {
+ status = report.status_of(c, 0),
+ findings = kept,
+ dismissed = dismissed,
+ uncovered = c.uncovered[:],
+ truncated = c.truncated,
+ }
+ if as_json {
+ out, ok := report.encode(env)
+ if !ok {
+ fmt.eprintln("review: the report could not be encoded")
+ os.exit(1)
+ }
+ fmt.println(out)
+ return
+ }
+ fmt.print(report.render(env, verbose))
+ for gap in c.uncovered {
+ fmt.eprintfln("unread: %s: %s", gap.file, gap.reason)
+ }
+}
+
+// list prints what the sidecars read, for a look at the evidence before
+// any judgement of it.
+list :: proc(c: change.Change, t: tree.Tree) {
+ added := 0
+ for _, lines in c.added {
+ added += len(lines)
+ }
+ fmt.printfln("=== change: %d files, %d added lines ===", len(c.files), added)
for s in c.symbols {
mark := " exported" if s.exported else ""
fmt.printfln("symbol %s:%d %s %s%s", s.file, s.line, s.kind, s.name, mark)
@@ -53,21 +110,6 @@ main :: proc() {
for l in c.comments {
fmt.printfln("comment %s:%d %s", l.file, l.line, l.text)
}
- for name in c.uncovered {
- fmt.printfln("unread %s", name)
- }
- declared, indexed := change.index(t)
+ declared, _ := change.index(t)
fmt.printfln("=== index: %d declarations ===", len(declared))
- if !read_ok || !indexed {
- fmt.eprintln("review: a sidecar did not answer")
- os.exit(1)
- }
-}
-
-added :: proc(c: change.Change) -> int {
- n := 0
- for _, lines in c.added {
- n += len(lines)
- }
- return n
}