Skip to content

Commit de97dfd

Browse files
authored
Merge pull request #105 from qiankunli/fix/filereader-rename-fatal-json
fix: file_read rename awareness + fatal-run JSON contract
2 parents 800f071 + 60f9239 commit de97dfd

7 files changed

Lines changed: 192 additions & 10 deletions

File tree

cmd/ccr/output.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,13 @@ func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
176176
}
177177

178178
type jsonSummary struct {
179-
FilesReviewed int64 `json:"files_reviewed"`
180-
Comments int64 `json:"comments"`
181-
TotalTokens int64 `json:"total_tokens"`
182-
InputTokens int64 `json:"input_tokens"`
183-
OutputTokens int64 `json:"output_tokens"`
184-
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
185-
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
179+
FilesReviewed int64 `json:"files_reviewed"`
180+
Comments int64 `json:"comments"`
181+
TotalTokens int64 `json:"total_tokens"`
182+
InputTokens int64 `json:"input_tokens"`
183+
OutputTokens int64 `json:"output_tokens"`
184+
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
185+
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
186186
// ElapsedSec is the review's wall-clock cost in whole seconds — numeric so
187187
// consumers (e.g. an MR-comment header) never parse a Go duration string.
188188
ElapsedSec int64 `json:"elapsed_sec"`
@@ -276,6 +276,29 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
276276
return enc.Encode(out)
277277
}
278278

279+
// outputJSONFatal emits the failed-run JSON shape. In --format json the stdout
280+
// contract is "always exactly one JSON object": a fatal run error must not
281+
// leave stdout empty, or downstream parsers report their own parse failure
282+
// instead of the actual error. Warnings ride along — on an all-units-failed
283+
// run they carry the per-unit error reasons.
284+
func outputJSONFatal(runErr error, warnings []agent.AgentWarning) error {
285+
out := jsonOutput{
286+
Status: "failed",
287+
Version: Version,
288+
Message: runErr.Error(),
289+
Comments: []model.LlmComment{},
290+
ToolCalls: &jsonToolCalls{
291+
ByTool: map[string]int64{},
292+
},
293+
}
294+
if len(warnings) > 0 {
295+
out.Warnings = warnings
296+
}
297+
enc := json.NewEncoder(os.Stdout)
298+
enc.SetIndent("", " ")
299+
return enc.Encode(out)
300+
}
301+
279302
func outputJSONNoFiles() error {
280303
out := jsonOutput{
281304
Status: "skipped",

cmd/ccr/output_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package main
22

3-
import "testing"
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io"
7+
"os"
8+
"testing"
9+
10+
"github.com/qiankunli/case-code-review/internal/agent"
11+
)
412

513
func TestSanitizeTerminal(t *testing.T) {
614
tests := []struct {
@@ -33,3 +41,42 @@ func TestSanitizeTerminal(t *testing.T) {
3341
})
3442
}
3543
}
44+
45+
func TestOutputJSONFatal_EmitsParseableFailedJSON(t *testing.T) {
46+
old := os.Stdout
47+
r, w, err := os.Pipe()
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
os.Stdout = w
52+
emitErr := outputJSONFatal(
53+
errors.New("all 3 unit review(s) failed"),
54+
[]agent.AgentWarning{{Type: "unit_error", File: "a.go", Message: "context deadline exceeded"}},
55+
)
56+
w.Close()
57+
os.Stdout = old
58+
if emitErr != nil {
59+
t.Fatal(emitErr)
60+
}
61+
62+
raw, err := io.ReadAll(r)
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
var out jsonOutput
67+
if err := json.Unmarshal(raw, &out); err != nil {
68+
t.Fatalf("stdout is not valid JSON: %v\n%s", err, raw)
69+
}
70+
if out.Status != "failed" {
71+
t.Errorf("status = %q, want failed", out.Status)
72+
}
73+
if out.Message != "all 3 unit review(s) failed" {
74+
t.Errorf("message = %q", out.Message)
75+
}
76+
if len(out.Warnings) != 1 || out.Warnings[0].Type != "unit_error" {
77+
t.Errorf("warnings not carried through: %+v", out.Warnings)
78+
}
79+
if out.Comments == nil {
80+
t.Error("comments must be [] not null")
81+
}
82+
}

cmd/ccr/review_cmd.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ func runReview(args []string) error {
119119
comments, err := ag.Run(ctx)
120120
if err != nil {
121121
telemetry.SetAttr(span, "error", err.Error())
122+
// Fatal or not, json mode must put one JSON object on stdout — the
123+
// exit code still signals failure for CLI callers.
124+
if opts.outputFormat == "json" {
125+
_ = outputJSONFatal(err, ag.Warnings())
126+
}
122127
return fmt.Errorf("review failed: %w", err)
123128
}
124129

cmd/ccr/scan_cmd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ func runScan(args []string) error {
213213
comments, err := ag.Run(ctx)
214214
if err != nil {
215215
telemetry.SetAttr(span, "error", err.Error())
216+
// Same stdout contract as review: json mode always emits one JSON object.
217+
if opts.outputFormat == "json" {
218+
_ = outputJSONFatal(err, ag.Warnings())
219+
}
216220
return fmt.Errorf("scan failed: %w", err)
217221
}
218222

internal/agent/agent.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,22 +447,37 @@ func (a *Agent) loadDiffs(ctx context.Context) error {
447447
}
448448

449449
// injectDiffMap builds a read-only DiffMap from parsed diffs and sets it
450-
// on the FileReadDiffProvider. Must be called after loadDiffs and before
451-
// any concurrent access to the registry.
450+
// on the FileReadDiffProvider, plus the rename/delete path map on the
451+
// FileReadProvider (so a read of a path this diff moved or removed gets
452+
// redirected/explained instead of a raw git miss). Must be called after
453+
// loadDiffs and before any concurrent access to the registry.
452454
func (a *Agent) injectDiffMap() {
453455
m := make(map[string]string, len(a.diffs))
456+
renamedTo := make(map[string]string)
457+
deleted := make(map[string]bool)
454458
for i := range a.diffs {
455459
d := &a.diffs[i]
456460
if d.NewPath != "/dev/null" {
457461
m[d.NewPath] = d.Diff
458462
}
463+
if d.IsRenamed && d.OldPath != "" && d.NewPath != "" && d.OldPath != d.NewPath {
464+
renamedTo[d.OldPath] = d.NewPath
465+
}
466+
if d.IsDeleted && d.OldPath != "" && d.OldPath != "/dev/null" {
467+
deleted[d.OldPath] = true
468+
}
459469
}
460470
dm := tool.NewDiffMap(m)
461471
if p, ok := a.args.Tools.Get(tool.FileReadDiff.Name()); ok {
462472
if frd, ok := p.(*tool.FileReadDiffProvider); ok {
463473
frd.SetDiffMap(dm)
464474
}
465475
}
476+
if p, ok := a.args.Tools.Get(tool.FileRead.Name()); ok {
477+
if frp, ok := p.(*tool.FileReadProvider); ok {
478+
frp.SetDiffPaths(tool.NewDiffPaths(renamedTo, deleted))
479+
}
480+
}
466481
}
467482

468483
// dispatchUnits runs the Plan + Main phases for each review Unit concurrently.

internal/tool/file_read.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,46 @@ package tool
33
import (
44
"context"
55
"fmt"
6+
"maps"
67
"strings"
78
)
89

910
const fileReadMaxLines = 500
1011

12+
// DiffPaths records what this review's diff did to paths that no longer exist
13+
// at the review ref (renames and deletions). A file_read miss on such a path
14+
// is the model following a stale reference from the diff itself (rename
15+
// headers, leftover imports), so it gets redirected or explained instead of
16+
// surfacing a raw git error the model can't act on. Frozen after
17+
// construction; safe for concurrent reads.
18+
type DiffPaths struct {
19+
renamedTo map[string]string // old path -> new path
20+
deleted map[string]bool // old path -> removed in this diff
21+
}
22+
23+
// NewDiffPaths creates a frozen DiffPaths from plain maps.
24+
func NewDiffPaths(renamedTo map[string]string, deleted map[string]bool) DiffPaths {
25+
r := make(map[string]string, len(renamedTo))
26+
maps.Copy(r, renamedTo)
27+
d := make(map[string]bool, len(deleted))
28+
maps.Copy(d, deleted)
29+
return DiffPaths{renamedTo: r, deleted: d}
30+
}
31+
1132
// FileReadProvider reads file content at a given path and optional line range.
1233
type FileReadProvider struct {
1334
FileReader *FileReader
35+
diffPaths DiffPaths
1436
}
1537

1638
func NewFileRead(fr *FileReader) *FileReadProvider { return &FileReadProvider{FileReader: fr} }
1739

40+
// SetDiffPaths installs the rename/delete map for this run. Must be called
41+
// before concurrent access begins (same contract as FileReadDiffProvider.SetDiffMap).
42+
func (p *FileReadProvider) SetDiffPaths(dp DiffPaths) {
43+
p.diffPaths = dp
44+
}
45+
1846
func (p *FileReadProvider) Tool() Tool { return FileRead }
1947

2048
func (p *FileReadProvider) Execute(ctx context.Context, args map[string]any) (string, error) {
@@ -44,6 +72,18 @@ func (p *FileReadProvider) Execute(ctx context.Context, args map[string]any) (st
4472
}
4573

4674
lines, totalLines, err := p.FileReader.ReadLines(ctx, filePath, int(startLine), maxLines)
75+
var renameNote string
76+
if err != nil {
77+
// The miss may be the model chasing a path this very diff moved or
78+
// removed (rename headers and stale imports keep the old path visible).
79+
if to, ok := p.diffPaths.renamedTo[filePath]; ok {
80+
renameNote = fmt.Sprintf("NOTE: %q was renamed to %q in this diff; showing the renamed file.\n", filePath, to)
81+
filePath = to
82+
lines, totalLines, err = p.FileReader.ReadLines(ctx, filePath, int(startLine), maxLines)
83+
} else if p.diffPaths.deleted[filePath] {
84+
return fmt.Sprintf("File %q was deleted in this diff; it no longer exists at the review ref. Use file_read_diff to see the removed content.", filePath), nil
85+
}
86+
}
4787
if err != nil {
4888
return "", fmt.Errorf("file %q not found: %w", filePath, err)
4989
}
@@ -62,6 +102,7 @@ func (p *FileReadProvider) Execute(ctx context.Context, args map[string]any) (st
62102
displayEnd := int(startLine) - 1 + len(lines)
63103

64104
var sb strings.Builder
105+
sb.WriteString(renameNote)
65106
sb.WriteString(fmt.Sprintf("File: %s (Total lines: %d)\n", filePath, totalLines))
66107
sb.WriteString(fmt.Sprintf("IS_TRUNCATED: %t\n", truncated))
67108
sb.WriteString(fmt.Sprintf("LINE_RANGE: %d-%d\n", int(startLine), displayEnd))

internal/tool/file_read_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,50 @@ func TestExecute_WithEndLine(t *testing.T) {
362362
t.Error("line 5 should not appear")
363363
}
364364
}
365+
366+
func TestExecute_RenamedPath_RedirectsToNewPath(t *testing.T) {
367+
dir := t.TempDir()
368+
writeTestFile(t, dir, "new.txt", "hello\nworld\n")
369+
370+
p := NewFileRead(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
371+
p.SetDiffPaths(NewDiffPaths(map[string]string{"old.txt": "new.txt"}, nil))
372+
373+
out, err := p.Execute(context.Background(), map[string]any{"file_path": "old.txt"})
374+
if err != nil {
375+
t.Fatal(err)
376+
}
377+
if !strings.Contains(out, `renamed to "new.txt"`) {
378+
t.Errorf("expected rename note, got:\n%s", out)
379+
}
380+
if !strings.Contains(out, "File: new.txt") {
381+
t.Errorf("expected redirected header, got:\n%s", out)
382+
}
383+
if !strings.Contains(out, "1|hello") {
384+
t.Errorf("expected redirected content, got:\n%s", out)
385+
}
386+
}
387+
388+
func TestExecute_DeletedPath_ExplainsDeletion(t *testing.T) {
389+
dir := t.TempDir()
390+
391+
p := NewFileRead(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
392+
p.SetDiffPaths(NewDiffPaths(nil, map[string]bool{"gone.txt": true}))
393+
394+
out, err := p.Execute(context.Background(), map[string]any{"file_path": "gone.txt"})
395+
if err != nil {
396+
t.Fatal(err)
397+
}
398+
if !strings.Contains(out, "was deleted in this diff") {
399+
t.Errorf("expected deletion explanation, got:\n%s", out)
400+
}
401+
}
402+
403+
func TestExecute_MissWithoutDiffPaths_StillErrors(t *testing.T) {
404+
dir := t.TempDir()
405+
406+
p := NewFileRead(&FileReader{RepoDir: dir, Mode: ModeWorkspace})
407+
408+
if _, err := p.Execute(context.Background(), map[string]any{"file_path": "nope.txt"}); err == nil {
409+
t.Fatal("expected not-found error for a path absent from the diff maps")
410+
}
411+
}

0 commit comments

Comments
 (0)