Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions internal/gitprovider/github/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,21 @@ func reconstructUnifiedDiff(files []pullFileResponse) string {
}
Comment thread
monit-reviewer marked this conversation as resolved.
if f.Patch == "" {
// A pure rename has no patch and is already fully described above.
// Anything else without a patch is a binary or oversized file; mark
// it so the change stays visible even though there are no hunks.
if f.Status != "renamed" {
// A removed file also has no patch when it is binary or individually
// oversized. Preserve its deletion metadata instead of falling through
// to the generic binary marker; otherwise the diff parser classifies
// the removal as a modified file and reviewer coverage includes the
// deleted path.
switch f.Status {
case "removed":
fmt.Fprintf(&b, "--- a/%s\n", oldPath)
b.WriteString("+++ /dev/null\n")
case "renamed":
// The rename metadata above fully describes a pure rename.
default:
// Anything else without a patch is a binary or oversized file;
// mark it so the change stays visible even though there are no
// hunks.
fmt.Fprintf(&b, "Binary files a/%s and b/%s differ\n", oldPath, newPath)
}
continue
Expand Down
8 changes: 7 additions & 1 deletion internal/gitprovider/github/rest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ func TestReconstructUnifiedDiff(t *testing.T) {
{Filename: "mod.go", Status: "modified", Patch: "@@ -1,2 +1,2 @@\n line\n-old\n+new"},
{Filename: "rn.go", PreviousFilename: "ro.go", Status: "renamed"}, // pure rename: no patch
{Filename: "bin.dat", Status: "added"}, // added, no patch: binary
{Filename: "removed.txt", Status: "removed"}, // removed, no patch: oversized text
{Filename: "removed.bin", Status: "removed"}, // removed, no patch: binary
})
cases := []struct {
substr string
Expand All @@ -468,7 +470,11 @@ func TestReconstructUnifiedDiff(t *testing.T) {
{"diff --git a/ro.go b/rn.go\nrename from ro.go\nrename to rn.go\n", true},
{"Binary files a/ro.go", false}, // a pure rename must not be marked binary
{"diff --git a/bin.dat b/bin.dat\nBinary files a/bin.dat and b/bin.dat differ\n", true},
{"+y", false}, // the empty-filename entry must be dropped entirely
{"diff --git a/removed.txt b/removed.txt\n--- a/removed.txt\n+++ /dev/null\n", true},
{"diff --git a/removed.bin b/removed.bin\nBinary files a/removed.bin and b/removed.bin differ\n", false},
{"diff --git a/removed.bin b/removed.bin\n--- a/removed.bin\n+++ /dev/null\n", true},
{"deleted file mode", false}, // no provider mode data: never fabricate one
{"+y", false}, // the empty-filename entry must be dropped entirely
}
for _, c := range cases {
if strings.Contains(got, c.substr) != c.want {
Expand Down
25 changes: 25 additions & 0 deletions internal/pipeline/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,28 @@ func TestParseUnifiedDiffRejectsBadHunkHeader(t *testing.T) {
t.Fatal("parseUnifiedDiff error = nil, want bad hunk failure")
}
}

func TestParseUnifiedDiffPreservesPatchlessRemovedStatus(t *testing.T) {
raw := strings.Join([]string{
"diff --git a/removed.txt b/removed.txt",
"--- a/removed.txt",
"+++ /dev/null",
"diff --git a/removed.bin b/removed.bin",
"--- a/removed.bin",
"+++ /dev/null",
"",
}, "\n")

parsed, err := parseUnifiedDiff(raw)
if err != nil {
t.Fatalf("parseUnifiedDiff: %v", err)
}
if len(parsed.Patches) != 2 {
t.Fatalf("patches = %#v, want two patchless deletions", parsed.Patches)
}
for _, patch := range parsed.Patches {
if !patch.Deleted || patch.Binary || patch.Path == "" || patch.OldPath != patch.Path || len(patch.Hunks) != 0 {
t.Fatalf("patchless deletion = %#v, want Deleted with no Binary flag or hunks", patch)
}
}
}
116 changes: 97 additions & 19 deletions internal/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ func executeLLMPhases(ctx context.Context, opts Options, req Request, mode execu
}

cohortScope := ledger.ReviewerCohortScope{PRKey: prepared.prKey, Profile: req.ProfileName, PostingIdentity: runlifecycle.PostingKey(req.PostingIdentity)}
selection, reviewerResumeIDs, reusedCohort, err := loadReviewerCohort(ctx, opts, req, cohortScope, prepared.catalog, prepared.changedFiles, maxAgents)
selection, reviewerResumeIDs, reusedCohort, err := loadReviewerCohort(ctx, opts, req, cohortScope, prepared.catalog, reviewablePatchPaths(prepared.parsed.Patches), maxAgents)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U-S1/U-D2: "the reviewable changed files" is now a real domain concept with no single owner. It is recomputed from raw patches at three independent call sites — here, 1258, and 2063 — while preparedSelectionContext.changedFiles (populated at 1179 from patchPaths) keeps the unqualified name for the other set (all paths, deletions included) and is still handed to 513, 692, 864, and 1056. Two same-named notions in one file is exactly the divergence that produced the bug this PR fixes, and the next consumer has nothing to tell it which set it wants.

A related symptom is 1259: selectionPromptInputFromArtifacts sets ChangedFiles from the durable dossier metadata (internal/pipeline/prompts.go:359) and the caller then overwrites it. A reader of the builder will conclude the dossier metadata is the source of truth; the override is easy to drop in the next refactor of that function, and there is no test that would notice.

Suggested fix: compute it once where changedFiles is populated — add reviewableFiles []string to preparedSelectionContext at 393/1179 and thread it to 783 and into selectionPhaseRequest — and rename changedFiles to something that says it includes deletions (allChangedFiles). Then pass the reviewable set into selectionPromptInputFromArtifacts/selectionPromptInputFromThreadContext as a parameter instead of overwriting the field after the fact, so selectionPromptInput.ChangedFiles has one owner.

Reply inline to this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining.

This is the largest of the three restructure requests: a new field on preparedSelectionContext, a rename of changedFiles across the 513/692/864/1056 call sites, and re-threading selectionPromptInputFromArtifacts to take the reviewable set as a parameter.

The underlying observation is fair — two same-named notions in one file is how the original bug happened. But that is a design change, and doing it inside a fix PR means the fix and the redesign land in one diff that has to be reviewed as both.

Keeping this PR to the defect. The naming belongs in its own change where it can be judged on its own terms.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepting the decline — the scope argument holds. A new field on preparedSelectionContext, a rename across the 513/692/864/1056 call sites, and re-threading selectionPromptInputFromArtifacts is a design change, and bundling it with the defect fix would force reviewers to judge two different things in one diff. This was a structural observation, not a defect blocking the fix.

Recording it here so the next change in this area has the context: the reviewable set is still recomputed at 783, 1258, and 2063, changedFiles still carries the unqualified name for the all-paths set, and selectionPromptInputFromArtifacts still sets ChangedFiles from dossier metadata only for the caller to overwrite it at 1259 — the override is the piece most likely to be dropped in a later refactor of that builder, and no test would catch it.

Resolving.

Summary:
Raised that "reviewable changed files" is now a durable domain concept with no single owner: it is recomputed from raw patches at three independent call sites (pipeline.go:783, 1258, 2063) while preparedSelectionContext.changedFiles (populated at 1179 from patchPaths) keeps the unqualified name for the all-paths set fed to 513, 692, 864, and 1056. Two same-named notions in one file is the divergence that produced the bug this PR fixes. A related symptom: selectionPromptInputFromArtifacts sets ChangedFiles from durable dossier metadata (prompts.go:359) and the caller overwrites it at 1259, so the builder reads as the source of truth while the override is untested and easy to drop. Author declined for scope: the proposed remedy — add reviewableFiles to preparedSelectionContext at 393/1179, thread it through selectionPhaseRequest, rename changedFiles to allChangedFiles across its call sites, and pass the reviewable set into the prompt builders as a parameter instead of overwriting the field — is a design change that would land alongside the defect fix in one diff. Deferred to a dedicated follow-up change; no defect is outstanding in this PR.

if err != nil {
return executionPhaseFailure(err)
}
Expand Down Expand Up @@ -1254,6 +1254,14 @@ func runSelectionPhase(ctx context.Context, opts Options, req selectionPhaseRequ
if err != nil {
return llm.Selection{}, sessionDraft{}, ledger.Session{}, err
}
// Deleted files remain in the dossier so the change is visible to the
// orchestrator, but they are not reviewer obligations: there is no file at
// the head for a reviewer to inspect. Keep them out of the assignment
// contract and the post-selection backstops, matching buildReviewerCoverage.
reviewerFiles := reviewablePatchPaths(req.ParsedDiff.Patches)
// Citing a removed or pre-rename path must not fail the whole selection.
selectableFiles := append(append([]string(nil), reviewerFiles...), mentionableExtraPaths(req.ParsedDiff.Patches)...)
promptInput.ChangedFiles = append([]string(nil), reviewerFiles...)
dependencyTaskIDs := []string{dossier.SummaryTaskID}
fingerprintDeps := append(append([]string(nil), dependencyTaskIDs...), promptDeps...)
selectionPrompt, err := buildSelectionPrompt(req.Catalog, promptInput, req.MaxAgents, req.SelectionPromptInstructions)
Expand All @@ -1270,7 +1278,7 @@ func runSelectionPhase(ctx context.Context, opts Options, req selectionPhaseRequ
decode := func(data []byte) (llm.Selection, error) {
return llm.DecodeSelection(data, llm.SelectionOptions{
KnownAgents: knownAgents(req.Catalog),
ChangedFiles: changedFiles(req.ParsedDiff.Patches),
ChangedFiles: stringSet(selectableFiles),
KnownThreads: knownThreadIDs,
})
}
Expand Down Expand Up @@ -1303,7 +1311,8 @@ func runSelectionPhase(ctx context.Context, opts Options, req selectionPhaseRequ
if err != nil {
return llm.Selection{}, selectionSession, ledgerSession, err
}
Comment thread
monit-reviewer marked this conversation as resolved.
changed := patchPaths(req.ParsedDiff.Patches)
changed := reviewerFiles
selection = filterSelectedReviewerAssignments(selection, changed)
selection = ensureRequiredOnMatchAgents(selection, req.Catalog, changed)
selection, err = opts.capSelectionAgents(selection, req.Catalog, changed, req.MaxAgents)
if err != nil {
Expand Down Expand Up @@ -2056,8 +2065,10 @@ func runReviewer(ctx context.Context, opts Options, req Request, runID string, p
return llm.Findings{}, sessionDraft{}, ledger.Session{}, nil, Failure(FailureTerminal, err)
}
model, effort := runtimeConfig.model, runtimeConfig.effort
changedFilePaths := patchPaths(parsed.Patches)
assignmentScope := reviewerAssignmentScope(selected, changedFilePaths)
changedFilePaths := reviewablePatchPaths(parsed.Patches)
Comment thread
monit-reviewer marked this conversation as resolved.
selected = filterSelectedReviewerAssignment(selected, changedFilePaths)
Comment thread
monit-reviewer marked this conversation as resolved.
// A reviewer may cite its own assignment plus unassignable paths, nothing else.
citableFiles := append(append([]string(nil), reviewerAssignmentScope(selected, changedFilePaths)...), mentionableExtraPaths(parsed.Patches)...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U-I1, close call: the enforced acceptance set is now right — assignment scope plus unassignable paths — but nothing tells the reviewer the extras are citable, so the capability the last two commits restored is only reachable when a model disregards its stated contract. buildReviewerPrompt passes only reviewerAssignmentScope(selected, changedFiles) to findingsOutputContract (prompts.go:30, 719), which advertises it as allowed_values.changed_files and instructs "file_path must be one of changed_files" (prompts.go:705). A compliant reviewer that notices "deleting X breaks Y" will therefore self-censor the finding, because X is absent from the only allowed set it was shown — even though the gate at 2119 would now accept it. Both settled threads justified widening with "the path is visible in the diff the model reads", which is true, but the model is simultaneously told those paths are not allowed values.

Secondary symptom at this line: the assignment scope is now derived twice from the same inputs — here, and again inside buildReviewerPrompt — so the advertised set and the enforced set are independent computations that can drift, which is the same recomputation hazard recorded in the 783 thread.

Suggested fix (small, no signature churn beyond one parameter): pass the citable set into buildReviewerPrompt and have findingsOutputContract advertise the two roles distinctly — keep changed_files as the assignment scope so inspected_files/skipped_files obligations stay narrow, and add e.g. also_citable_files (the mentionableExtraPaths result) with a one-line instruction that file_path may be one of either. That makes the prompt describe what the gate enforces and removes the duplicate derivation.

Defensible either way, and not blocking: if the intent is a deliberately tolerant gate — accept such a finding when a model volunteers it, without inviting commentary on deleted code — then the narrow prompt is a reasonable choice and a sentence saying so at this line would settle it for the next reader. What would change my verdict is evidence that the narrow prompt is intentional rather than an oversight; the commit messages and thread replies argue the capability matters, which is why I read the silence as unfinished rather than chosen.

Reply inline to this comment.

prompt, promptDeps, err := buildReviewerPrompt(artifacts, pr, selected, agent, changedFilePaths, resumeState.discussion)
if err != nil {
return llm.Findings{}, sessionDraft{}, ledger.Session{}, nil, Failure(FailureTerminal, err)
Expand Down Expand Up @@ -2108,7 +2119,7 @@ func runReviewer(ctx context.Context, opts Options, req Request, runID string, p
}, func(data []byte) (llm.Findings, error) {
return llm.DecodeFindings(data, llm.FindingsOptions{
KnownAgents: map[string]bool{agent.ID: true},
ChangedFiles: stringSet(assignmentScope),
ChangedFiles: stringSet(citableFiles),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The citableFiles gate is the right shape for file_path, but llm.FindingsOptions.ChangedFiles is also the gate for inspected_files/skipped_files (contracts.go:264), and coverage accounting no longer has the scope guarantee it documents. Before this PR the gate was exactly assignmentScope, so result.InspectedFiles ⊆ scope held by construction; now it is scope + mentionableExtraPaths, and buildReviewerCoverage copies inspected files through without intersecting scope — entry.InspectedFiles = filterReviewableFiles(copySortedStrings(result.InspectedFiles)) at pipeline.go:2773, one line above entry.SkippedFiles = sortedIntersection(result.SkippedFiles, scope). The comment right above it (2770-2772) still asserts a reviewer 'doesn't emit an inspected file outside its scope', which is now false.

Impact is durable-output accuracy rather than correctness: a reviewer that lists a deleted path or a rename source in inspected_files gets it written into the coverage row, and reviewplan renders that row as 'inspected N assigned files' (internal/reviewplan/summary.go:325-331) — labelling as assigned a path the reviewer was deliberately never assigned, and inflating the cross-reviewer inspected union at summary.go:559-566. Approval logic is unaffected because coverageMissingFiles only subtracts from scope.

Fix: mirror the SkippedFiles line — entry.InspectedFiles = sortedIntersection(filterReviewableFiles(result.InspectedFiles), scope) — so findings may cite unassignable paths while coverage rows stay an obligation ledger, and the 2770-2772 comment becomes true again. If instead the extras are meant to show up in coverage, update that comment and the 'assigned' wording in summary.go so the two surfaces agree.

Reply inline to this comment.

NewFindingID: opts.newFindingID,
})
})
Expand Down Expand Up @@ -2622,6 +2633,49 @@ func deletedPatchPaths(patches []FilePatch) map[string]bool {
return deleted
}

// renamedPatchOldPaths returns the pre-rename paths still present in the diff.
// They are not reviewer obligations, but an orchestrator may cite one because
// the "rename from" header is visible in the dossier diff.
func renamedPatchOldPaths(patches []FilePatch) []string {
Comment thread
monit-reviewer marked this conversation as resolved.
paths := make([]string, 0, len(patches))
for _, patch := range patches {
if patch.Deleted || patch.OldPath == "" || patch.OldPath == patch.Path {
continue
}
paths = append(paths, patch.OldPath)
}
sort.Strings(paths)
return paths
}

// mentionableExtraPaths returns paths a model may cite but is never assigned:
// removed files and the pre-rename sources still visible in the diff.
func mentionableExtraPaths(patches []FilePatch) []string {
deleted := deletedPatchPaths(patches)
seen := make(map[string]bool, len(deleted))
paths := make([]string, 0, len(patches))
for path := range deleted {
seen[path] = true
paths = append(paths, path)
}
for _, path := range renamedPatchOldPaths(patches) {
if seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}

// reviewablePatchPaths returns changed paths that a reviewer can inspect at
// the head. Deleted paths remain in ParsedDiff and the dossier, but are not a
// reviewer assignment or coverage obligation.
func reviewablePatchPaths(patches []FilePatch) []string {
return excludeFiles(patchPaths(patches), deletedPatchPaths(patches))
}

// excludeFiles returns values with any member of exclude removed, preserving order.
func excludeFiles(values []string, exclude map[string]bool) []string {
if len(exclude) == 0 {
Expand All @@ -2636,6 +2690,43 @@ func excludeFiles(values []string, exclude map[string]bool) []string {
return out
}

func filterAssignmentFiles(files, changedFiles []string) []string {
changed := stringSet(changedFiles)
var filtered []string
for _, file := range files {
if changed[file] && !slices.Contains(filtered, file) {
filtered = append(filtered, file)
}
}
return copySortedStrings(filtered)
}

func filterSelectedReviewerAssignment(selected llm.SelectedAgent, changedFiles []string) llm.SelectedAgent {
selected.Files = filterAssignmentFiles(selected.Files, changedFiles)
selected.AllowedFiles = filterAssignmentFiles(selected.AllowedFiles, changedFiles)
return selected
}

// filterSelectedReviewerAssignments removes deleted paths from explicit
// assignments and drops a selected reviewer whose only assignment was
// deleted. Broad selections remain broad when reviewable paths exist.
func filterSelectedReviewerAssignments(selection llm.Selection, changedFiles []string) llm.Selection {
Comment thread
zzwong marked this conversation as resolved.
filtered := selection
filtered.SelectedAgents = nil
if len(changedFiles) == 0 {
return filtered
}
for _, selected := range selection.SelectedAgents {
hadExplicitAssignment := len(selected.Files) > 0 || len(selected.AllowedFiles) > 0
selected = filterSelectedReviewerAssignment(selected, changedFiles)
if hadExplicitAssignment && len(selected.Files) == 0 && len(selected.AllowedFiles) == 0 {
continue
}
filtered.SelectedAgents = append(filtered.SelectedAgents, selected)
}
return filtered
}

func buildReviewerCoverage(selected []llm.SelectedAgent, results []llm.Findings, failures []ReviewerFailure, changedFiles []string, deleted map[string]bool, toolEvidence ...map[string]*llm.ReviewerToolEvidence) []reviewplan.ReviewerCoverageSummary {
if len(selected) == 0 && len(changedFiles) == 0 {
return nil
Expand Down Expand Up @@ -3217,19 +3308,6 @@ func knownAgents(catalog agents.Catalog) map[string]bool {
return setBy(catalog.Agents, func(agent agents.Agent) string { return agent.ID })
}

func changedFiles(patches []FilePatch) map[string]bool {
paths := make([]string, 0, len(patches)*2)
for _, patch := range patches {
if patch.Path != "" {
paths = append(paths, patch.Path)
}
if patch.OldPath != "" {
paths = append(paths, patch.OldPath)
}
}
return stringSet(paths)
}

func knownThreads(threads []gitprovider.InlineThread) map[string]bool {
return threadIDSet(threads, func(thread gitprovider.InlineThread) gitprovider.ThreadID { return thread.ID })
}
Expand Down
Loading
Loading