open-code-review/cmd/opencodereview/output.go
Gongyl01 0ce730a3c8
feat(manifest): run manifest coverage contract for review (#367) (#520)
* feat(session): add run manifest coverage data model and builder

First slice of issue #367 (run manifest coverage contract): the data
model and state machine only. Not yet wired into the agent or CLI, so
existing review/scan output is unchanged.

Introduce the versioned, immutable RunManifest (schema ocr.run-manifest/v1)
and a concurrency-safe ManifestBuilder that tracks per-file coverage
(selected/completed/reused/failed/waived) and freezes into a terminal
state.

- terminal state derived solely from coverage sets, never comments/warnings
  (complete/partial/failed/skipped)
- Finalize sweeps any undecided selected item to failed/unknown so no item
  is silently dropped
- single-mutex builder: first terminal state wins, frozen after Finalize,
  nil-receiver safe
- fixed failure classification enum with an unknown catch-all
- redaction floor on failure/waive reasons (strip secrets, cap length) as a
  single write entry so callers cannot bypass it
- 22 unit tests, race-clean

Refs: issue #367
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(session): harden run manifest per adversarial review

Address findings from the concurrency / JSON-contract / PR#306-coupling
adversarial review of the manifest data model (still slice 1; not wired to
agent or CLI).

- SetSweepClass: Finalize can classify undispatched items as cancelled/budget
  instead of a blanket unknown (the one real model gap the review found)
- ItemID(fingerprint)=SHA-256 canonical mint helper; an item_id is never a raw
  fingerprint, keeping the resume cross-reference explicit and mix-ups caught
- sanitizeReason: strip control/ANSI chars, coerce valid UTF-8, redact quoted
  secret values, guarantee single line
- Finalize returns deep-copied coverage slices so the frozen snapshot is never
  aliased across the two outlets
- RegisterSelected: nil-safe (lazy-init map) + documents that only the
  post-deletion/post-filter dispatchable set may be registered

+7 unit tests (29 total), race-clean.

Refs: issue #367
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(manifest): wire input identity, config hashes and run-level failure (shard ②d)

- Freeze per-mode input identity (mode + resolved_base/head + exact_range +
  source_artifact_sha256) via diff.ResolveInput/commitParents, and repository
  identity via RemoteIdentity/canonicalRemote (credential-free).
- Add rule_config_sha256 and runtime_config_sha256 over an allowlist of
  non-secret fields using a length-prefixed SHA-256 framework (no tokens/URLs).
- Replace SetRunLevelFailure(bool) with structured SetRunFailure(class, reason)
  and set ManifestInput.mode; fill execution.* (ocr version, provider, model,
  concurrency, config hashes).
- Thread error returns through Finalize/WriteSessionEnd (main review path
  surfaces them; skip/all-failed/scan paths hardened in follow-up).
- Tests: manifest_hash, canonical_config, git_resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(manifest): propagate persistence errors and harden remote/error classification

Merged review themes A/B/E from the 07-22 consolidated assessment.

Theme A — Finalize / session_end delivery errors no longer swallowed:
- agent.go no-files path returns the Finalize error instead of nil (A1)
- agent.go loadDiffs failure joins the Finalize error via errors.Join (A2)
- session.Finalize uses sync.Once + cached finalizeErr: written exactly
  once, concurrency-safe, and every caller replays the same result so a
  retry cannot falsely report success (A3)
- scan/agent.go wires both Finalize call sites to surface the error (A4)

Theme B — canonicalRemote rewritten (internal/diff/git.go):
- keep the port (u.Host, not u.Hostname) so endpoints differing only by
  port stay distinct (B1)
- split scp syntax on the first ':' so an '@' inside the path survives (B2)
- recognize local/file/Windows/UNC remotes and omit identity rather than
  misparsing a path as a host (B3; local-remote policy still open)

Theme E — main_task-empty is now a sentinel (errMainTaskEmpty) classified
via errors.Is instead of matching error text.

Theme D (TOCTOU) deferred to shard 4 per issue #367 open-issues OI-12.

Tests: go build ./... + go vet + go test ./... all green (23 pkgs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(manifest): report both dispatch and persistence errors on the normal path

The success-path Finalize wiring used `ferr != nil && err == nil`, so when the
review (or scan) failed AND session_end also failed to persist, the persistence
error was dropped and only the dispatch error surfaced — the caller never
learned the session/manifest was not saved.

Join both with errors.Join when both occur (matching the loadDiffs path), so a
persistence failure is always reported even alongside a dispatch failure. This
closes the last gap in the OI-10 contract.

- internal/agent/agent.go: review normal path
- internal/scan/agent.go: scan normal path (+ errors import)

Tests: go build ./... + go vet + go test ./... all green (23 pkgs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(manifest): 接入 CLI 与 viewer 并补齐验收用例

- 使用冻结 manifest 统一 review JSON、文本与退出状态\n- session CLI 和 viewer 展示五集合覆盖并兼容 legacy/aborted\n- 补充本地 mock、跨出口一致性及安全验收用例

* test(manifest): 补齐验收矩阵缺口并修复审核发现的缺陷

验收用例:configuration 分类(run 级 sweep + item 级映射)、budget/timeout/panic 混合 partial 隔离、跨出口一致性改为规范化原始字节比对、flag 校验失败无产物断言。

代码修复:sanitizeReason 先剥控制字符再脱敏(堵控制字节绕过)、失败项异分类二次标记报冲突错误、source_artifact_sha256 按 item_id 去重并稳定排序、sortItems 改 SliceStable 对齐设计用词。

全仓 go test 23 包通过。

* test(manifest): 补充 provider transition resume 测试用例

覆盖 issue #367 验收标准 provider transition:resume 时 provider/model 改变后,子 manifest 记录当前值而非继承父运行,并经 parent_run_id 链接父会话以支持审计。用 mock client,不依赖真实 provider key。

* fix(manifest): 对齐预算终态与持久化语义

统一聚合预算停止时的 coverage、status 与退出码。传播 session writer 初始化错误,并补齐 merge first-parent 输入身份及回归测试。移除代码注释中的外部设计文档引用。

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: kite <254839944+lizhengfeng101@users.noreply.github.com>
2026-08-01 16:50:21 +08:00

530 lines
16 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"unicode"
"github.com/alibaba/open-code-review/internal/agent"
"github.com/alibaba/open-code-review/internal/model"
"github.com/alibaba/open-code-review/internal/session"
"github.com/alibaba/open-code-review/internal/suggestdiff"
)
func outputText(comments []model.LlmComment) {
if len(comments) == 0 {
fmt.Println("No comments generated. Looks good to me.")
return
}
for _, c := range comments {
renderComment(c)
}
}
func hasSubtaskErrors(warnings []agent.AgentWarning) bool {
for _, w := range warnings {
if isSubtaskErrorType(w.Type) {
return true
}
}
return false
}
// warningsForOutput removes coverage-level subtask diagnostics once a manifest
// is present. Their classification and safe summary already live in the frozen
// coverage.failed set; retaining the original warning would duplicate that fact
// and could expose the provider's raw error text in JSON. Non-coverage warnings
// remain visible, and legacy output keeps its existing warning behavior.
func warningsForOutput(warnings []agent.AgentWarning, manifest *session.RunManifest) []agent.AgentWarning {
if manifest == nil || len(warnings) == 0 {
return warnings
}
filtered := make([]agent.AgentWarning, 0, len(warnings))
for _, warning := range warnings {
if !isSubtaskErrorType(warning.Type) {
filtered = append(filtered, warning)
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func isSubtaskErrorType(warningType string) bool {
return warningType == "subtask_error" || warningType == "scan_subtask_error"
}
func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, manifest *session.RunManifest) {
if manifest != nil {
fmt.Println(manifestMessage(manifest, len(comments)))
for _, c := range comments {
renderComment(c)
}
} else if len(comments) == 0 {
if hasSubtaskErrors(warnings) {
fmt.Println("Some files could not be reviewed due to errors (see warnings below).")
} else {
fmt.Println("No comments generated. Looks good to me.")
}
} else {
for _, c := range comments {
renderComment(c)
}
}
for _, w := range warnings {
if isSubtaskErrorType(w.Type) {
continue
}
fmt.Fprintf(os.Stderr, "[ocr] WARNING [%s] %s: %s\n", w.Type, sanitizeTerminal(w.File), sanitizeTerminal(w.Message))
}
}
func renderComment(comment model.LlmComment) {
lines := buildDiffLines(comment)
if len(lines) == 0 && comment.Content == "" {
return
}
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
if comment.Content != "" {
badge := buildBadge(comment)
content := sanitizeTerminal(comment.Content)
if badge != "" {
// Prepend the plain badge text to the content so it wraps inline with
// the first line, then colorize just the badge prefix after wrapping.
content = badge + " " + content
}
lines := wrapByRunes(content, 100)
for i, ln := range lines {
if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
color := severityColor(comment.Severity)
ln = color + badge + "\033[0m" + ln[len(badge):]
}
fmt.Printf("%s\n", ln)
}
fmt.Println()
}
if len(lines) > 0 {
for _, dl := range lines {
switch dl.Type {
case suggestdiff.DiffAdded:
printDiffLine("+", sanitizeTerminal(dl.Content), "\033[92m", "\033[48;2;0;60;0m")
case suggestdiff.DiffDeleted:
printDiffLine("-", sanitizeTerminal(dl.Content), "\033[91m", "\033[48;2;70;0;0m")
case suggestdiff.DiffContext:
printDiffLine(" ", sanitizeTerminal(dl.Content), "\033[2m", "\033[48;2;38;38;38m")
}
}
}
fmt.Println()
}
// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
// an empty string when neither structured field is present, so text output for findings
// without metadata is unchanged.
func buildBadge(comment model.LlmComment) string {
category := sanitizeTerminal(comment.Category)
severity := sanitizeTerminal(comment.Severity)
switch {
case category != "" && severity != "":
return fmt.Sprintf("[%s · %s]", category, severity)
case category != "":
return fmt.Sprintf("[%s]", category)
case severity != "":
return fmt.Sprintf("[%s]", severity)
default:
return ""
}
}
// severityColor maps a finding severity to an ANSI color used for its badge.
// Unknown or empty severities fall back to dim.
func severityColor(severity string) string {
switch severity {
case "critical":
return "\033[1;91m" // bold bright red
case "high":
return "\033[91m" // bright red
case "medium":
return "\033[93m" // bright yellow
case "low":
return "\033[94m" // bright blue
default:
return "\033[2m" // dim
}
}
// printDiffLine renders a single diff line with colored prefix and background on content.
func printDiffLine(prefix, content, fgColor, bgColor string) {
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
}
// wrapByRunes splits text into lines that fit within maxWidth **rune** columns.
// Respects existing newlines and wraps at word boundaries.
func wrapByRunes(text string, maxW int) []string {
if text == "" {
return nil
}
var result []string
for _, para := range strings.Split(text, "\n") {
result = append(result, wrapSingleRuneLine(para, maxW)...)
}
return result
}
// wrapSingleRuneLine breaks one paragraph (no newlines) into rune-width-constrained lines.
func wrapSingleRuneLine(line string, maxW int) []string {
runes := []rune(line)
if visibleRunesLen(runes) <= maxW {
return []string{line}
}
var result []string
for len(runes) > 0 {
cut := runeWrapCut(runes, maxW)
result = append(result, string(runes[:cut]))
runes = runes[cut:]
// trim leading spaces of next segment
for len(runes) > 0 && runes[0] == ' ' {
runes = runes[1:]
}
}
return result
}
// runeWrapCut returns a rune index suitable for breaking the line at ~maxW display width.
func runeWrapCut(runes []rune, maxW int) int {
if visibleRunesLen(runes) <= maxW {
return len(runes)
}
best := maxW
if best >= len(runes) {
return len(runes)
}
for i := best; i > 0; i-- {
if runes[i] == ' ' || runes[i] == '\t' {
return i
}
}
return best
}
func visibleRunesLen(runes []rune) int {
n := 0
for _, r := range runes {
if r >= 32 && r != 127 {
n++
}
}
return n
}
func sanitizeTerminal(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '\t' || r == '\n' || !unicode.IsControl(r) {
b.WriteRune(r)
}
}
return b.String()
}
func splitToLines(s string) []string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
if comment.SuggestionCode == "" || comment.ExistingCode == "" {
return nil
}
oldLines := splitToLines(comment.ExistingCode)
newLines := splitToLines(comment.SuggestionCode)
return suggestdiff.ComputeLineDiff(oldLines, newLines)
}
type jsonSummary struct {
FilesReviewed int64 `json:"files_reviewed"`
Comments int64 `json:"comments"`
TotalTokens int64 `json:"total_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
Elapsed string `json:"elapsed"`
BudgetExceeded bool `json:"budget_exceeded,omitempty"`
}
type jsonToolCalls struct {
Total int64 `json:"total"`
ByTool map[string]int64 `json:"by_tool"`
}
type jsonOutput struct {
Status string `json:"status"`
TraceID string `json:"trace_id,omitempty"`
Message string `json:"message,omitempty"`
Summary *jsonSummary `json:"summary,omitempty"`
ToolCalls *jsonToolCalls `json:"tool_calls"`
Comments []model.LlmComment `json:"comments"`
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
ProjectSummary string `json:"project_summary,omitempty"`
Resume *agent.ResumeInfo `json:"resume,omitempty"`
SessionID string `json:"session_id,omitempty"`
Manifest *session.RunManifest `json:"manifest,omitempty"`
}
func outputJSON(comments []model.LlmComment) error {
out := jsonOutput{
Status: "success",
Comments: comments,
}
if len(comments) == 0 {
out.Message = "No comments generated. Looks good to me."
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string,
manifest *session.RunManifest, budgetExceeded bool) error {
publishedWarnings := warningsForOutput(warnings, manifest)
out := jsonOutput{
Status: "success",
TraceID: traceID,
Comments: comments,
Summary: &jsonSummary{
FilesReviewed: filesReviewed,
Comments: int64(len(comments)),
TotalTokens: totalTokens,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheReadTokens: cacheReadTokens,
CacheWriteTokens: cacheWriteTokens,
Elapsed: duration.Round(time.Second).String(),
BudgetExceeded: budgetExceeded,
},
ProjectSummary: projectSummary,
Resume: resumeInfo,
SessionID: sessionID,
Manifest: manifest,
}
var total int64
for _, v := range toolCalls {
total += v
}
byTool := toolCalls
if byTool == nil {
byTool = make(map[string]int64)
}
out.ToolCalls = &jsonToolCalls{
Total: total,
ByTool: byTool,
}
if manifest != nil {
out.Status = string(manifest.TerminalState)
out.Message = manifestMessage(manifest, len(comments))
} else if len(comments) == 0 {
if hasSubtaskErrors(warnings) {
out.Message = "Some files could not be reviewed due to errors."
} else {
out.Message = "No comments generated. Looks good to me."
}
}
if len(publishedWarnings) > 0 {
out.Warnings = publishedWarnings
if manifest == nil && hasSubtaskErrors(publishedWarnings) {
out.Status = "completed_with_errors"
} else if manifest == nil {
out.Status = "completed_with_warnings"
}
}
// budgetExceeded deliberately does NOT touch out.Status. Reaching the
// aggregate token budget is a controlled coverage truncation, so it is already
// expressed in the manifest as failed(budget) on the items that never got
// dispatched — which makes terminal_state read "partial" whenever anything was
// covered. The status set above is therefore the single source of truth,
// and the budget reason stays observable through three deterministic outlets:
// summary.budget_exceeded, the token_budget_reached warning, and
// coverage.failed[].classification == "budget".
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func manifestMessage(manifest *session.RunManifest, findings int) string {
if manifest == nil {
return ""
}
selected := len(manifest.Coverage.Selected)
failed := len(manifest.Coverage.Failed)
waived := len(manifest.Coverage.Waived)
switch manifest.TerminalState {
case session.StateComplete:
if waived > 0 {
return fmt.Sprintf("Review complete: %d finding(s) across %d selected item(s), including %d waived.", findings, selected, waived)
}
return fmt.Sprintf("Review complete: %d finding(s) across %d selected item(s).", findings, selected)
case session.StatePartial:
return fmt.Sprintf("Review partially complete: %d finding(s); %d of %d selected item(s) failed.", findings, failed, selected)
case session.StateFailed:
if manifest.RunFailure != nil {
return fmt.Sprintf("Review failed (%s): %d finding(s); %d of %d selected item(s) failed.", manifest.RunFailure.Classification, findings, failed, selected)
}
return fmt.Sprintf("Review failed: %d finding(s); %d of %d selected item(s) failed.", findings, failed, selected)
case session.StateSkipped:
return "Review skipped: no items were selected."
default:
return fmt.Sprintf("Review finished with unknown manifest state %q.", manifest.TerminalState)
}
}
func outputJSONNoFiles(traceID string) error {
out := jsonOutput{
Status: "skipped",
TraceID: traceID,
Message: "No supported files changed.",
Comments: []model.LlmComment{},
ToolCalls: &jsonToolCalls{
ByTool: map[string]int64{},
},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// emitFailureUsage writes a best-effort structured usage record to stderr when
// a review fails, so the outer caller still sees the cost of the failed attempt.
// It carries only token/tool-call tallies and elapsed, never credentials or
// prompts.
//
// A plain aggregate budget stop does NOT reach here: it is a controlled coverage
// truncation, so it yields terminal_state=partial and a nil error. It only
// arrives when the truncation left nothing covered at all (every selected item
// failed(budget) ⇒ terminal_state=failed), or alongside an unrelated failure.
// Whenever the manifest was constructed, stdout has already published the
// complete frozen result before this runs — so this record supplements it, never
// replaces it. We report the agent's actual BudgetExceeded() value rather than
// hardcoding false, so the record can never contradict the agent's state.
//
// In json format it emits a jsonOutput-shaped object to stderr (kept separate
// from stdout so it does not pollute the machine-readable result stream, which
// therefore always carries exactly one JSON document); otherwise a single
// human-readable [ocr] line. It must never return an error that masks the
// original failure — all writes are best-effort.
func emitFailureUsage(ag ResultProvider, duration time.Duration, outputFormat string) {
var toolTotal int64
for _, v := range ag.ToolCalls() {
toolTotal += v
}
budgetExceeded := ag.BudgetExceeded()
if outputFormat == "json" {
out := jsonOutput{
Status: "failed",
Summary: &jsonSummary{
FilesReviewed: ag.FilesReviewed(),
TotalTokens: ag.TotalTokensUsed(),
InputTokens: ag.TotalInputTokens(),
OutputTokens: ag.TotalOutputTokens(),
CacheReadTokens: ag.TotalCacheReadTokens(),
CacheWriteTokens: ag.TotalCacheWriteTokens(),
Elapsed: duration.Round(time.Second).String(),
BudgetExceeded: budgetExceeded,
},
ToolCalls: &jsonToolCalls{
Total: toolTotal,
ByTool: ag.ToolCalls(),
},
SessionID: ag.SessionID(),
}
enc := json.NewEncoder(os.Stderr)
enc.SetIndent("", " ")
_ = enc.Encode(out)
return
}
fmt.Fprintf(os.Stderr, "[ocr] usage on failure: %d file(s), %d input + %d output = %d total tokens, %d tool calls, elapsed %s, budget_exceeded=%v",
ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
toolTotal, duration.Round(time.Second).String(), budgetExceeded)
if id := ag.SessionID(); id != "" {
fmt.Fprintf(os.Stderr, ", session %s", id)
}
fmt.Fprintln(os.Stderr)
}
func outputPreviewText(p *agent.DiffPreview) {
if p.TotalFiles == 0 {
fmt.Println("No files changed.")
return
}
maxPathLen := 0
for _, e := range p.Entries {
if n := len(sanitizeTerminal(e.Path)); n > maxPathLen {
maxPathLen = n
}
}
if maxPathLen < 20 {
maxPathLen = 20
}
pathFmt := fmt.Sprintf("%%-%ds", maxPathLen)
fmt.Printf("\nPreview: %d file(s) changed | \033[32m+%d\033[0m \033[31m-%d\033[0m\n",
p.TotalFiles, p.TotalInsertions, p.TotalDeletions)
if p.ReviewableCount > 0 {
fmt.Printf("\n\033[1mWill review (%d):\033[0m\n", p.ReviewableCount)
for _, e := range p.Entries {
if !e.WillReview {
continue
}
fmt.Printf(" %s "+pathFmt+" \033[32m+%-4d\033[0m \033[31m-%-4d\033[0m\n",
statusBadge(e.Status), sanitizeTerminal(e.Path), e.Insertions, e.Deletions)
}
}
if p.ExcludedCount > 0 {
fmt.Printf("\n\033[1mExcluded from review (%d):\033[0m\n", p.ExcludedCount)
for _, e := range p.Entries {
if e.WillReview {
continue
}
fmt.Printf(" %s "+pathFmt+" \033[2m(%s)\033[0m\n",
statusBadge(e.Status), sanitizeTerminal(e.Path), sanitizeTerminal(string(e.ExcludeReason)))
}
}
fmt.Println()
}
func statusBadge(status string) string {
switch status {
case "added":
return "\033[32m[A]\033[0m"
case "modified":
return "\033[33m[M]\033[0m"
case "deleted":
return "\033[31m[D]\033[0m"
case "renamed":
return "\033[36m[R]\033[0m"
case "binary":
return "\033[35m[B]\033[0m"
case "scan":
return "\033[34m[S]\033[0m"
default:
return "[?]"
}
}