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
9 changes: 6 additions & 3 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ Which lines of a commit the AI wrote.

| Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 |
|---------|---------------|-------------|--------|--------------|------------|
| Line attribution | Each changed line of a commit is attributed to the AI or to a human, from a snapshot of each file before and after the agent edits it. | Yes | Partial | Yes | Yes |
| Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Yes | Partial | Yes | Yes |
| Line attribution | Each changed line of a commit is attributed to the AI or to a human, from a snapshot of each file before and after the agent edits it. An added line is AI when its content matches a line that an agent edit of the session wrote. A line that a human changed is human, also when a later agent edit keeps it. A whitespace-only change keeps the owner of the line. A removed line is AI only when an agent edit removed it. In a commit of more than one session, a line that only another session wrote or removed counts for neither side in this session's evidence (spec issue-3639). | Yes | Partial | Yes | Yes |
| Partial commits | A commit that holds only part of the AI lines of a file credits the session with those lines. The other AI lines stay pending, and the next commit with them is credited to the session too (spec issue-3639). | Yes | Partial | Yes | Yes |
Comment thread
jiparis marked this conversation as resolved.
| Generated line counts | The trace state keeps the lines that each agent edit added and removed, also the lines that never reach a commit. The evidence does not report them yet (spec issue-3639). | Yes | Partial | Yes | Yes |
| Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Partial | Partial | Partial | Partial |

Why not Yes:

- **Line attribution, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits.
- **Line attribution, partial commits and generated line counts, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits.
- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI.
- **Shell changes attribution, Cursor:** the provider installs the `preToolUse` and `postToolUse` hooks for the `Shell` tool. These hooks are verified for the Cursor IDE only. It is not verified that the Cursor CLI sends them.

## User experience
Expand Down
114 changes: 87 additions & 27 deletions app/cli/internal/trace/attribution/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package attribution

import (
"bytes"
"strings"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
"github.com/sergi/go-diff/diffmatchpatch"
Expand All @@ -29,57 +28,118 @@ import (
// deletion-only changes where no "after" ranges exist.
// If before is nil, the entire file is considered new.
func ComputeLineRanges(before, after []byte) ([]aicodingsession.LineRange, bool) {
if bytes.Equal(before, after) {
return nil, false
}
edit := ComputeEdit(before, after)

afterStr := string(after)
if afterStr == "" {
// Deletion-only: before had content, after is empty.
return nil, true
}
return edit.Ranges, edit.Changed
}

if before == nil {
lineCount := strings.Count(afterStr, "\n")
if !strings.HasSuffix(afterStr, "\n") {
lineCount++
}
return []aicodingsession.LineRange{{Start: 1, End: lineCount}}, true
// Edit is what one change did to a file: the line ranges it wrote in the new
// content, and the lines it added and removed.
type Edit struct {
// Changed is true whenever the content differs, including a change that
// only removes lines.
Changed bool
// Ranges are the 1-indexed, inclusive line ranges of the new content that
// the change wrote.
Ranges []aicodingsession.LineRange
// AddedCount and RemovedCount are the lines the change added and removed,
// counted as the line diff shows them.
AddedCount, RemovedCount int
// Added and Removed are the fingerprints of the lines the change added and
// removed. A line that the change only re-indented or moved shows as one
// removal and one addition with the same fingerprint; such pairs are left
// out, because the change wrote no new code on that line (D-008).
Added, Removed []string
}

// ComputeEdit diffs the content of a file before and after a change. A nil
// before means the file is new, and a nil or empty after means the change
// removed all of its lines.
func ComputeEdit(before, after []byte) Edit {
if bytes.Equal(before, after) {
return Edit{}
}

beforeStr := string(before)
edit := Edit{Changed: true}

dmp := diffmatchpatch.New()
beforeChars, afterChars, lines := dmp.DiffLinesToChars(beforeStr, afterStr)
beforeChars, afterChars, lines := dmp.DiffLinesToChars(string(before), string(after))
diffs := dmp.DiffMain(beforeChars, afterChars, false)
diffs = dmp.DiffCharsToLines(diffs, lines)
// Clean up while each character still stands for a whole line. On the
// expanded text the cleanup factors out common prefixes and suffixes, and
// a diff entry could then hold part of a line.
diffs = dmp.DiffCleanupSemantic(diffs)
diffs = dmp.DiffCharsToLines(diffs, lines)

var ranges []aicodingsession.LineRange
afterLine := 1

for _, d := range diffs {
lineCount := strings.Count(d.Text, "\n")
if len(d.Text) > 0 && !strings.HasSuffix(d.Text, "\n") {
lineCount++
}
text := splitLines(d.Text)

switch d.Type {
case diffmatchpatch.DiffEqual:
afterLine += lineCount
afterLine += len(text)
case diffmatchpatch.DiffInsert:
if lineCount > 0 {
if len(text) > 0 {
ranges = append(ranges, aicodingsession.LineRange{
Start: afterLine,
End: afterLine + lineCount - 1,
End: afterLine + len(text) - 1,
})
afterLine += lineCount
afterLine += len(text)
}
edit.AddedCount += len(text)
for _, l := range text {
edit.Added = append(edit.Added, Fingerprint(l))
}
case diffmatchpatch.DiffDelete:
// Lines removed from "before" — don't advance afterLine.
edit.RemovedCount += len(text)
for _, l := range text {
edit.Removed = append(edit.Removed, Fingerprint(l))
}
}
}

edit.Ranges = mergeAdjacentRanges(ranges)
edit.Added, edit.Removed = dropCommon(edit.Added, edit.Removed)

return edit
}

// dropCommon removes from a and b each fingerprint that both hold, one
// occurrence for one occurrence.
func dropCommon(a, b []string) ([]string, []string) {
if len(a) == 0 || len(b) == 0 {
return a, b
}

inB := make(map[string]int, len(b))
for _, fp := range b {
inB[fp]++
}

common := make(map[string]int)
keptA := a[:0]
for _, fp := range a {
if inB[fp] > 0 {
inB[fp]--
common[fp]++
continue
}
keptA = append(keptA, fp)
}

keptB := b[:0]
for _, fp := range b {
if common[fp] > 0 {
common[fp]--
continue
}
keptB = append(keptB, fp)
}

return mergeAdjacentRanges(ranges), true
return keptA, keptB
}

// mergeAdjacentRanges merges overlapping or adjacent line ranges.
Expand Down
Loading
Loading