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
3 changes: 2 additions & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,13 @@ Which lines of a commit the AI wrote.
| 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 | No |
| Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the sum of the removals of the session's commits (spec issue-3639). | Partial | Partial | Partial | Partial | No |
| Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | No |
| Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution is an AI range at edit time. Older CLIs write only these ranges. This CLI writes them for a file that it cannot blame at the pushed commit, and for a file with edits from before line fingerprints (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | No |
| Renames and rewrites | The AI/human split survives a file rename, an amend, a rebase, a squash, and a cherry-pick. The lines match the trace state by content, and blame follows a renamed file to its lines from before the rename (spec issue-3639). | Partial | Partial | Partial | Partial | No |

Why not Yes:

- **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.
- **Net contribution, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too.
- **Net contribution and line ranges, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too.
- **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends.
- **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. For Cursor, the limits of line attribution apply too.
- **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.
Expand Down
35 changes: 31 additions & 4 deletions app/cli/internal/trace/attribution/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ type CommitLines struct {
// back to the sum of the added lines of each commit. Removed lines are always
// the sum over the session's own commits.
//
// The line ranges of a file in tip are the positions of the session's net
// added lines in the pushed commit, each range AI or human (spec issue-3639,
// line ranges R-001, R-002). They hold as many lines as the net counts
// (R-003). A file that tip does not hold keeps the AI ranges at edit time,
// with no attribution, as older CLIs write them (R-005).
//
// A file that was renamed after a commit of the session has the lines of its
// new path in tip (R-007). A line there counts for the path that its commit
// changed, so a session that changed the file under both paths counts each
Expand All @@ -75,13 +81,15 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
}

byPath := splitByPath(sessionID, self, ledgers, commits)
positioned := make(map[string][]aicodingsession.LineRange, len(tip))
for path, net := range netAdded(sessionID, self, ledgers, commits, tip) {
s, ok := byPath[path]
if !ok {
s = &fileSplit{}
byPath[path] = s
}
s.aiAdded, s.humanAdded = net.aiAdded, net.humanAdded
positioned[path] = net.ranges
}

oldPaths := make(map[string][]string)
Expand Down Expand Up @@ -109,6 +117,9 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
} else {
f.Attribution = trace.AttributionHuman
}
if ranges, ok := positioned[f.Path]; ok {
f.LineRanges = ranges
}

switch s, ok := byPath[f.Path]; {
case self.Legacy[f.Path]:
Expand All @@ -134,6 +145,19 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
// fileSplit is the AI/human split of the changed lines of one file.
type fileSplit struct {
aiAdded, aiRemoved, humanAdded, humanRemoved int
// ranges are the positions of the net added lines in the pushed commit.
ranges []aicodingsession.LineRange
}

// addLine adds line number n, owned by owner, to the ranges. A line next to
// the last range with the same owner extends it.
func (s *fileSplit) addLine(n int, owner string) {
if last := len(s.ranges) - 1; last >= 0 && s.ranges[last].End == n-1 && s.ranges[last].Attribution == owner {
s.ranges[last].End = n
return
}

s.ranges = append(s.ranges, aicodingsession.LineRange{Start: n, End: n, Attribution: owner})
}

// editedRanges returns the recorded ranges of the session's edits of a file,
Expand Down Expand Up @@ -197,9 +221,9 @@ func splitByPath(sessionID string, self *state.AILineAttribution, ledgers map[st
}

// netAdded splits the lines of each file in tip that the session's commits
// wrote. A line from a commit outside the session is not the session's. A
// line from a joined commit counts as human when its replacing hunks added
// it, and not at all otherwise.
// wrote, and records the position of each one. A line from a commit outside
// the session is not the session's. A line from a joined commit counts as
// human when its replacing hunks added it, and not at all otherwise.
func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[string]*state.AILineAttribution, commits []CommitLines, tip map[string][]tracegit.BlameLine) map[string]*fileSplit {
bySHA := make(map[string]CommitLines, len(commits))
for _, c := range commits {
Expand All @@ -216,7 +240,7 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin
s := &fileSplit{}
out[path] = s

for _, l := range tip[path] {
for i, l := range tip[path] {
commit, ok := bySHA[l.SHA]
if !ok {
continue
Expand All @@ -226,6 +250,7 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin
if commit.Joined != nil {
if joinedPool(joined, commit, path, tip).Take(fp) {
s.humanAdded++
s.addLine(i+1, trace.AttributionHuman)
}

continue
Expand All @@ -241,9 +266,11 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin
switch {
case written.takeFile(self, fl, fp):
s.aiAdded++
s.addLine(i+1, trace.AttributionAI)
case written.takeAny(otherLedgers(sessionID, ledgers, commit.SessionIDs), fl, fp):
default:
s.humanAdded++
s.addLine(i+1, trace.AttributionHuman)
}
}
}
Expand Down
54 changes: 45 additions & 9 deletions app/cli/internal/trace/attribution/enrich_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ func TestEnrich_Legacy(t *testing.T) {

assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution)
assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs)
assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 5}}, changes.Files[0].LineRanges,
"a file with no blame keeps the ranges at edit time, with no attribution (R-005)")
assert.Equal(t, 5, changes.AILinesAdded)
assert.Equal(t, 2, changes.AILinesRemoved)
assert.Equal(t, 0, changes.HumanLinesAdded)
Expand Down Expand Up @@ -307,6 +309,12 @@ func TestEnrich_Net(t *testing.T) {
return out
}
five := []string{"a", "b", "c", "d", "e"}
ai := func(start, end int) aicodingsession.LineRange {
return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionAI}
}
human := func(start, end int) aicodingsession.LineRange {
return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionHuman}
}
ownCommit := func(sha string, added, removed []string) CommitLines {
return CommitLines{SHA: sha, SessionIDs: []string{testSession}, Files: []tracegit.FileLines{{Path: testFile, Added: added, Removed: removed}}}
}
Expand All @@ -317,6 +325,9 @@ func TestEnrich_Net(t *testing.T) {
commits []CommitLines
tip map[string][]tracegit.BlameLine
want split
// ranges are the positions of the session's lines at the pushed
// commit (line ranges spec R-001, R-002).
ranges []aicodingsession.LineRange
}{
{
// Case 3 of the spec: a later commit of the session replaces an
Expand All @@ -327,8 +338,9 @@ func TestEnrich_Net(t *testing.T) {
ownCommit(c1, five, nil),
ownCommit(c2, []string{"C"}, []string{"c"}),
},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")},
want: split{aiAdded: 4, humanAdded: 1, humanRemoved: 1},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")},
want: split{aiAdded: 4, humanAdded: 1, humanRemoved: 1},
ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)},
},
{
// Case 2 of the spec: a commit in no session replaces an AI line
Expand All @@ -341,8 +353,9 @@ func TestEnrich_Net(t *testing.T) {
{SHA: c2, Files: []tracegit.FileLines{{Path: testFile, Added: []string{"C", "x"}, Removed: []string{"c"}}},
Joined: map[string][]string{testFile: fps("C")}},
},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e", c2+":x")},
want: split{aiAdded: 4, humanAdded: 1},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e", c2+":x")},
want: split{aiAdded: 4, humanAdded: 1},
ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)},
},
{
// Case 4 of the spec: a commit of another session replaces the
Expand All @@ -352,6 +365,7 @@ func TestEnrich_Net(t *testing.T) {
commits: []CommitLines{ownCommit(c1, five, nil)},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c3+":C", c1+":d", c1+":e")},
want: split{aiAdded: 4},
ranges: []aicodingsession.LineRange{ai(1, 2), ai(4, 5)},
},
{
name: "a line of another session of the commit counts for neither",
Expand All @@ -361,8 +375,9 @@ func TestEnrich_Net(t *testing.T) {
},
commits: []CommitLines{{SHA: c1, SessionIDs: []string{testSession, otherSession},
Files: []tracegit.FileLines{{Path: testFile, Added: []string{"a", "b", "h"}}}}},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c1+":h")},
want: split{aiAdded: 1, humanAdded: 1},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c1+":h")},
want: split{aiAdded: 1, humanAdded: 1},
ranges: []aicodingsession.LineRange{ai(1, 1), human(3, 3)},
},
{
name: "a file that the pushed commit does not hold adds nothing",
Expand All @@ -374,6 +389,16 @@ func TestEnrich_Net(t *testing.T) {
tip: map[string][]tracegit.BlameLine{testFile: nil},
want: split{aiRemoved: 1, humanRemoved: 5},
},
{
// D-003: the human lines of the session get ranges too, also in a
// file that no agent edit of the session touched.
name: "a file that only a human changed in the session has human ranges",
ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, "b.go", nil, nil)},
commits: []CommitLines{ownCommit(c1, []string{"x", "y"}, nil)},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c3+":k", c1+":x", c1+":y")},
want: split{humanAdded: 2},
ranges: []aicodingsession.LineRange{human(2, 3)},
},
{
// R-007: the session wrote old.go, a commit renamed it to the
// file, and a later commit of the session changed the file. The
Expand All @@ -395,7 +420,8 @@ func TestEnrich_Net(t *testing.T) {
oldFile: tipOf(c1+":a", c1+":b", c1+":c", c1+":d", c1+":e", c2+":f"),
testFile: tipOf(c1+":a", c1+":b", c1+":c", c1+":d", c1+":e", c2+":f"),
},
want: split{aiAdded: 1},
want: split{aiAdded: 1},
ranges: []aicodingsession.LineRange{ai(6, 6)},
},
{
// R-007: a commit in no session renamed the file and replaced a
Expand All @@ -407,8 +433,9 @@ func TestEnrich_Net(t *testing.T) {
{SHA: c2, Files: []tracegit.FileLines{{Path: "new.go", OldPath: testFile, Added: []string{"C"}, Removed: []string{"c"}}},
Joined: map[string][]string{"new.go": fps("C")}},
},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")},
want: split{aiAdded: 4, humanAdded: 1},
tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")},
want: split{aiAdded: 4, humanAdded: 1},
ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)},
},
}

Expand All @@ -421,6 +448,15 @@ func TestEnrich_Net(t *testing.T) {
f := changes.Files[0]
assert.Equal(t, tc.want, split{f.AILinesAdded, f.AILinesRemoved, f.HumanLinesAdded, f.HumanLinesRemoved})
assert.Equal(t, tc.want, split{changes.AILinesAdded, changes.AILinesRemoved, changes.HumanLinesAdded, changes.HumanLinesRemoved})
assert.Equal(t, tc.ranges, f.LineRanges)

// R-003: the ranges hold as many lines as the net added lines.
covered := map[string]int{}
for _, r := range f.LineRanges {
covered[r.Attribution] += r.End - r.Start + 1
}
assert.Equal(t, f.AILinesAdded, covered[trace.AttributionAI], "AI ranges agree with the AI count")
assert.Equal(t, f.HumanLinesAdded, covered[trace.AttributionHuman], "human ranges agree with the human count")
})
}
}
Expand Down
1 change: 1 addition & 0 deletions app/cli/internal/trace/e2e/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ var features = map[string]Feature{
"Line attribution": {Checks: []Check{{scenarioEditAndCommit, checkFileAttributedToAI("greeting.txt")}}},
"Shell changes attribution": {Checks: []Check{{scenarioEditAndCommit, checkFileAttributedToAI("shell.txt")}}},
"Partial commits": {Checks: []Check{{scenarioPartial, checkBothCommitsCredited}}},
"Line ranges": {Checks: []Check{{scenarioEditAndCommit, checkLineRanges("greeting.txt", 3)}}},
"Generated line counts": {
NotCovered: "neither the user nor the evidence can see the generated line counts yet",
},
Expand Down
23 changes: 23 additions & 0 deletions app/cli/internal/trace/e2e/checks_more.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"strings"
"time"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/pointer"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
Expand Down Expand Up @@ -220,3 +221,25 @@ func checkMediaKept(s *Session) error {
}
return nil
}

// checkLineRanges checks that the evidence gives the lines of a file that the
// agent wrote as one AI range, at their positions in the pushed commit.
func checkLineRanges(path string, lines int) func(s *Session) error {
return func(s *Session) error {
cc := evidence(s).CodeChanges
if cc == nil {
return errors.New("the evidence has no code changes")
}
want := []aicodingsession.LineRange{{Start: 1, End: lines, Attribution: trace.AttributionAI}}
for _, f := range cc.Files {
if f.Path != path {
continue
}
if !slices.Equal(f.LineRanges, want) {
return fmt.Errorf("%s: line ranges %+v, want %+v", path, f.LineRanges, want)
}
return nil
}
return fmt.Errorf("the code changes do not list %s", path)
}
}
91 changes: 91 additions & 0 deletions app/cli/pkg/action/trace_line_attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"strings"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
Expand Down Expand Up @@ -362,6 +363,96 @@ func TestLineAttribution_Case4OtherSessionReplaces(t *testing.T) {
assert.Equal(t, lineSplit{humanAdded: 1, humanRemoved: 1}, fileSplitOf(t, got[other].changes, "a.go"))
}

func rangesOf(t *testing.T, cc *aicodingsession.CodeChanges, path string) []aicodingsession.LineRange {
t.Helper()
for _, f := range cc.Files {
if f.Path == path {
return f.LineRanges
}
}
require.Failf(t, "file not in the code changes", "%s", path)

return nil
}

func aiRange(start, end int) aicodingsession.LineRange {
return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionAI}
}

func humanRange(start, end int) aicodingsession.LineRange {
return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionHuman}
}

// Spec issue-3639 line ranges R-001 and R-004: the AI writes 100 lines, and a
// later commit replaces 20 of them by hand. Each push sends ranges at the
// positions of the pushed commit, with their owner.
func TestLineAttribution_RangesPointIntoPushedCommit(t *testing.T) {
testCases := []struct {
name string
// session is the session of the later commit, empty for none.
session string
}{
{name: "later commit in no session (cases 2 and 5)"},
{name: "later commit in the same session (case 3)", session: lineSession},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := newLineRepo(t)

lines := numbered("ai", 100)
r.agentWrite(lineSession, "a.go", content(lines))
r.commit("feat: a", "a.go")
first := r.push()
require.Contains(t, first, lineSession)
assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 100)}, rangesOf(t, first[lineSession].changes, "a.go"))

for i := 40; i < 60; i++ {
lines[i] = "human " + lines[i]
}
r.humanWrite("a.go", content(lines))
files := []string{"a.go"}
if tc.session != "" {
r.agentWrite(tc.session, "b.go", "package b\n")
files = append(files, "b.go")
}
r.commit("fix: by hand", files...)

second := r.push()
require.Contains(t, second, lineSession, "the session is sent again")
cc := second[lineSession].changes
split := fileSplitOf(t, cc, "a.go")
assert.Equal(t, 80, split.aiAdded)
assert.Equal(t, 20, split.humanAdded)
assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 40), humanRange(41, 60), aiRange(61, 100)}, rangesOf(t, cc, "a.go"))
})
}
}

// Spec issue-3639 line ranges R-002: in case 4, each session gets ranges only
// for its own lines.
func TestLineAttribution_RangesOfTwoSessions(t *testing.T) {
r := newLineRepo(t)
const other = "sess-other"

lines := numbered("ai", 5)
r.agentWrite(lineSession, "a.go", content(lines))
r.commit("feat: a", "a.go")
r.push()

lines[1] = "human in b"
r.humanWrite("a.go", content(lines))
r.agentWrite(other, "b.go", "package b\n")
r.commit("feat: b", "a.go", "b.go")

got := r.push()
require.Contains(t, got, lineSession)
require.Contains(t, got, other)
assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 1), aiRange(3, 5)}, rangesOf(t, got[lineSession].changes, "a.go"))
assert.Equal(t, []aicodingsession.LineRange{humanRange(2, 2)}, rangesOf(t, got[other].changes, "a.go"))
assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 1)}, rangesOf(t, got[other].changes, "b.go"))
}

// R-005: lines that the agent removed are AI removals.
func TestLineAttribution_AgentRemovalIsAI(t *testing.T) {
r := newLineRepo(t)
Expand Down
Loading