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