diff --git a/internal/receipt/markdown.go b/internal/receipt/markdown.go index f18a7ca..81884bb 100644 --- a/internal/receipt/markdown.go +++ b/internal/receipt/markdown.go @@ -91,7 +91,7 @@ func WriteMarkdown(w io.Writer, r schema.Receipt) { if cov.SuppressedMentions > 0 { fmt.Fprintf(w, "%s\n", mentionNote(cov.SuppressedMentions)) } - fmt.Fprintf(w, "\nschema %s · exit gate: refuted claims block; the remainder informs, never fails\n", r.SchemaVersion) + fmt.Fprintf(w, "\nschema %s%s · exit gate: refuted claims block; the remainder informs, never fails\n", r.SchemaVersion, toolNote(r)) } // mdCell makes text safe inside a markdown table cell. diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index 729e5ae..4bb83f8 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -11,7 +11,9 @@ import ( "path" "path/filepath" "regexp" + "runtime/debug" "strings" + "sync" "github.com/joshft/correctful/internal/gitdiff" "github.com/joshft/correctful/schema" @@ -59,6 +61,7 @@ func Assemble(change gitdiff.Change, claims []schema.Claim, evidence [][]schema. return schema.Receipt{ SchemaVersion: schema.SchemaVersion, + ToolVersion: toolVersion(), Change: schema.ChangeRef{ // The receipt carries the repository NAME, never its location: a // receipt is shareable, and external-tool details are scrubbed of @@ -86,6 +89,45 @@ func Assemble(change gitdiff.Change, claims []schema.Claim, evidence [][]schema. } } +// toolVersion identifies this checker build from its own build info — the +// first chain field: two receipts are comparable across time only when the +// harvesters and runners that produced them are known. Module version plus +// short VCS revision when the build is stamped (`go install` from a clone +// stamps both; a dirty tree gains "+dirty"), "unknown" when the build +// carries no identity — stated, never guessed. +var toolVersion = sync.OnceValue(func() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + v := bi.Main.Version + rev, dirty := "", false + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + rev = s.Value + case "vcs.modified": + dirty = s.Value == "true" + } + } + if len(rev) > 12 { + rev = rev[:12] + } + if rev != "" && dirty { + rev += "+dirty" + } + switch { + case v != "" && rev != "": + return v + " " + rev + case v != "": + return v + case rev != "": + return rev + default: + return "unknown" + } +}) + // anchoringSummary tallies the binding layer's resolution outcomes. Nil when // no claim carries an anchor — a repo without a definition corpus. func anchoringSummary(claims []schema.Claim) *schema.AnchoringSummary { @@ -350,7 +392,7 @@ func WriteJSON(w io.Writer, r schema.Receipt) error { // every other tool hides. func WriteText(w io.Writer, r schema.Receipt) { s := r.Summary - fmt.Fprintf(w, "correctful receipt (schema %s)\n", r.SchemaVersion) + fmt.Fprintf(w, "correctful receipt (schema %s%s)\n", r.SchemaVersion, toolNote(r)) fmt.Fprintf(w, "change: %s...%s%s", r.Change.BaseRef, r.Change.HeadRef, shaNote(r.Change)) if r.Change.Repo != "" { fmt.Fprintf(w, " [%s]", r.Change.Repo) @@ -438,6 +480,15 @@ func mentionNote(n int) string { return fmt.Sprintf("%d spec-id mention(s) not minted as claims — the repo defines no spec-id corpus, so a reference has no possible referent", n) } +// toolNote renders the producing build beside the schema version — shared by +// both renderers so the chain field is visible wherever the receipt is read. +func toolNote(r schema.Receipt) string { + if r.ToolVersion == "" { + return "" + } + return " · correctful " + r.ToolVersion +} + // detailOf picks the evidence detail a reader needs: for a refuted claim, the // FAILING probe's detail — a claim with five probes where the fourth failed // must not display the first probe's "ok". diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go index 661ec69..1eef2f8 100644 --- a/internal/receipt/receipt_test.go +++ b/internal/receipt/receipt_test.go @@ -492,3 +492,24 @@ func TestLLMVerifiedRowKeepsProvenance(t *testing.T) { } } } + +// TestReceiptCarriesToolVersion: the first chain field. Every receipt names +// the build that produced it — "unknown" when the build carries no identity, +// never empty — and both renderers show it beside the schema version. +func TestReceiptCarriesToolVersion(t *testing.T) { + claims, evidence := sampleClaims() + r := Assemble(gitdiff.Change{BaseRef: "main", HeadRef: "wip"}, claims, evidence, schema.Coverage{}) + if r.ToolVersion == "" { + t.Fatal("tool version empty — must be stated, or stated as unknown") + } + var text, md strings.Builder + WriteText(&text, r) + WriteMarkdown(&md, r) + want := "correctful " + r.ToolVersion + if !strings.Contains(text.String(), want) { + t.Errorf("text receipt lacks %q", want) + } + if !strings.Contains(md.String(), want) { + t.Errorf("markdown receipt lacks %q", want) + } +} diff --git a/schema/schema.go b/schema/schema.go index 771a3e1..4df49a8 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -374,9 +374,17 @@ type Coverage struct { // Receipt is the per-change output: what was claimed, what was verified, and — // the load-bearing part — the honest unverified remainder. type Receipt struct { - SchemaVersion string `json:"schema_version"` - Change ChangeRef `json:"change"` - Results []ClaimResult `json:"results"` + SchemaVersion string `json:"schema_version"` + // ToolVersion identifies the checker build that produced this receipt — + // module version plus VCS revision when the build carries them, so two + // receipts are comparable across time only when the harvesters and + // runners that produced them are known. One field covers every in-tree + // component (they ship in one binary); per-supplier versions arrive with + // the evidence-intake contract. "unknown" when the build carries no + // identity — stated, never guessed. + ToolVersion string `json:"tool_version,omitempty"` + Change ChangeRef `json:"change"` + Results []ClaimResult `json:"results"` // Remainder is the subset of Results with Status == StatusUnverified, // surfaced explicitly so a reader never has to derive it. This is the // feature no other tool in the field ships. @@ -386,4 +394,4 @@ type Receipt struct { } // SchemaVersion is the current version of the receipt schema (the payload). -const SchemaVersion = "0.0.9" +const SchemaVersion = "0.0.10"