返回 DeepSeek-Reasonix
review_report.go
根目录 / internal / evidence / review_report.go
1 package evidence
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8 )
9
10 // ReviewKind distinguishes ordinary review from security review reports.
11 type ReviewKind string
12
13 const (
14 ReviewKindReview ReviewKind = "review"
15 ReviewKindSecurity ReviewKind = "security"
16 )
17
18 // ReviewVerdict is the structured outcome of a review sub-agent.
19 type ReviewVerdict string
20
21 const (
22 ReviewVerdictPass ReviewVerdict = "pass"
23 ReviewVerdictWarn ReviewVerdict = "warn"
24 ReviewVerdictBlock ReviewVerdict = "block"
25 )
26
27 // ReviewFinding is one structured finding inside a review_report.
28 type ReviewFinding struct {
29 Severity string `json:"severity"`
30 Summary string `json:"summary"`
31 Path string `json:"path,omitempty"`
32 Line int `json:"line,omitempty"`
33 }
34
35 // ReviewReport is the structured payload submitted via the review_report tool.
36 type ReviewReport struct {
37 Kind ReviewKind `json:"kind"`
38 Verdict ReviewVerdict `json:"verdict"`
39 ReviewedPaths []string `json:"reviewed_paths"`
40 Findings []ReviewFinding `json:"findings"`
41 }
42
43 // ParseReviewReport validates and normalizes a review_report argument object.
44 func ParseReviewReport(raw json.RawMessage) (ReviewReport, error) {
45 var r ReviewReport
46 if err := json.Unmarshal(raw, &r); err != nil {
47 return ReviewReport{}, fmt.Errorf("invalid review_report JSON: %w", err)
48 }
49 r.Kind = ReviewKind(strings.ToLower(strings.TrimSpace(string(r.Kind))))
50 r.Verdict = ReviewVerdict(strings.ToLower(strings.TrimSpace(string(r.Verdict))))
51 switch r.Kind {
52 case ReviewKindReview, ReviewKindSecurity:
53 default:
54 return ReviewReport{}, fmt.Errorf("review_report.kind must be review or security")
55 }
56 switch r.Verdict {
57 case ReviewVerdictPass, ReviewVerdictWarn, ReviewVerdictBlock:
58 default:
59 return ReviewReport{}, fmt.Errorf("review_report.verdict must be pass, warn, or block")
60 }
61 r.ReviewedPaths = normalizePaths(r.ReviewedPaths)
62 if len(r.ReviewedPaths) == 0 {
63 return ReviewReport{}, fmt.Errorf("review_report.reviewed_paths must be non-empty")
64 }
65 clean := make([]ReviewFinding, 0, len(r.Findings))
66 for _, f := range r.Findings {
67 f.Severity = strings.TrimSpace(f.Severity)
68 f.Summary = strings.TrimSpace(f.Summary)
69 f.Path = strings.TrimSpace(f.Path)
70 if f.Summary == "" {
71 return ReviewReport{}, fmt.Errorf("review_report.findings require a non-empty summary")
72 }
73 if f.Severity == "" {
74 f.Severity = "info"
75 }
76 clean = append(clean, f)
77 }
78 r.Findings = clean
79 return r, nil
80 }
81
82 // CoversPaths reports whether every required production path was reviewed.
83 func (r ReviewReport) CoversPaths(required []string) bool {
84 if len(required) == 0 {
85 return len(r.ReviewedPaths) > 0
86 }
87 have := pathSet(normalizePaths(r.ReviewedPaths))
88 for _, p := range normalizePaths(required) {
89 if p == "" {
90 continue
91 }
92 if !have[p] {
93 // Also accept basename coverage for short relative refs.
94 found := false
95 base := filepathBase(p)
96 for h := range have {
97 if h == p || filepathBase(h) == base {
98 found = true
99 break
100 }
101 }
102 if !found {
103 return false
104 }
105 }
106 }
107 return true
108 }
109
110 // HasBlockingFinding reports whether the verdict forbids delivery.
111 func (r ReviewReport) HasBlockingFinding() bool {
112 if r.Verdict == ReviewVerdictBlock {
113 return true
114 }
115 for _, f := range r.Findings {
116 switch strings.ToLower(f.Severity) {
117 case "block", "blocking", "critical", "error":
118 return true
119 }
120 }
121 return false
122 }
123
124 // WarningSummaries returns human-readable warn-level findings for the final summary.
125 func (r ReviewReport) WarningSummaries() []string {
126 var out []string
127 if r.Verdict == ReviewVerdictWarn {
128 out = append(out, "review verdict=warn")
129 }
130 for _, f := range r.Findings {
131 switch strings.ToLower(f.Severity) {
132 case "warn", "warning", "medium":
133 msg := f.Summary
134 if f.Path != "" {
135 msg = f.Path + ": " + msg
136 }
137 out = append(out, msg)
138 }
139 }
140 return out
141 }
142
143 func filepathBase(p string) string {
144 p = strings.ReplaceAll(p, `\`, `/`)
145 if i := strings.LastIndex(p, "/"); i >= 0 {
146 return p[i+1:]
147 }
148 return p
149 }
150
151 // ReviewReportReceipt is stored on the ledger when a review_report succeeds.
152 type ReviewReportReceipt struct {
153 Report ReviewReport
154 After int // mutation index this report claims to cover; -1 if unknown
155 }
156
157 // HasStructuredReviewAfter reports whether a successful structured review of
158 // the given kind was recorded after the mutation, covering required paths, and
159 // without a blocking verdict.
160 func (l *Ledger) HasStructuredReviewAfter(kind ReviewKind, after int, requiredPaths []string) (ok bool, blocking bool, report *ReviewReport) {
161 if l == nil {
162 return false, false, nil
163 }
164 start := after + 1
165 if start < 0 {
166 start = 0
167 }
168 l.mu.Lock()
169 defer l.mu.Unlock()
170 for i := start; i < len(l.receipts); i++ {
171 r := l.receipts[i]
172 if !r.Success || r.ToolName != "review_report" {
173 continue
174 }
175 parsed, err := ParseReviewReport(r.Args)
176 if err != nil {
177 continue
178 }
179 if parsed.Kind != kind {
180 continue
181 }
182 if !parsed.CoversPaths(requiredPaths) {
183 continue
184 }
185 if parsed.HasBlockingFinding() {
186 return true, true, &parsed
187 }
188 return true, false, &parsed
189 }
190 return false, false, nil
191 }
192
193 // HasSuccessfulStructuredReviewAfter is a convenience for non-blocking coverage.
194 func (l *Ledger) HasSuccessfulStructuredReviewAfter(kind ReviewKind, after int, requiredPaths []string) bool {
195 ok, blocking, _ := l.HasStructuredReviewAfter(kind, after, requiredPaths)
196 return ok && !blocking
197 }
198
199 // HasSuccessfulReviewReportOfKind reports whether any successful review_report
200 // receipt of the given kind exists, regardless of mutation ordering or path
201 // coverage. Subagent completion gates use it: a review subagent that never
202 // submitted a typed report must fail its parent tool call instead of returning
203 // prose the delivery gate cannot verify.
204 func (l *Ledger) HasSuccessfulReviewReportOfKind(kind ReviewKind) bool {
205 if l == nil {
206 return false
207 }
208 l.mu.Lock()
209 defer l.mu.Unlock()
210 for _, r := range l.receipts {
211 if !r.Success || r.ToolName != "review_report" {
212 continue
213 }
214 parsed, err := ParseReviewReport(r.Args)
215 if err != nil {
216 continue
217 }
218 if parsed.Kind == kind {
219 return true
220 }
221 }
222 return false
223 }
224
225 // HasReadEvidenceForPath reports whether the host observed the CONTENT of
226 // path: a successful read receipt whose extracted paths equal the claimed
227 // path after normalization (or contain it as a slash-suffix of a fuller
228 // observed path), or a content-revealing bash command (diff/cmp/cat/head/
229 // tail, git diff/show) that names the path in its parsed argv AND produced
230 // non-empty host-observed output. Deliberately rejected: write receipts
231 // (writing is not reviewing), arbitrary path-mentioning commands like git
232 // status or echo, pipelines and redirects (they transform or swallow the
233 // content), summary flags (--stat, --name-only, -q), zero-output runs
234 // (head -n 0, >/dev/null), substring path hits (path.bak), and reverse
235 // basename suffix matching (a bare "agent.go" receipt must not satisfy a
236 // claim for a specific full path).
237 func (l *Ledger) HasReadEvidenceForPath(path string) bool {
238 p := normalizePath(path)
239 if l == nil || p == "" {
240 return false
241 }
242 needle := strings.ToLower(filepath.ToSlash(p))
243 l.mu.Lock()
244 defer l.mu.Unlock()
245 for _, r := range l.receipts {
246 if !r.Success {
247 continue
248 }
249 if r.Read {
250 for _, rp := range r.Paths {
251 o := strings.ToLower(filepath.ToSlash(normalizePath(rp)))
252 if o == needle || strings.HasSuffix(o, "/"+needle) {
253 return true
254 }
255 }
256 }
257 if r.ToolName == "bash" && r.OutputBytes > 0 && commandShowsContentForPath(r.Command, needle) {
258 return true
259 }
260 }
261 return false
262 }
263
263 lines GO