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
4 changes: 4 additions & 0 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ Which lines of a commit the AI wrote.
| 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 |
| 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 |
| 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 |
| 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 |

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.
- **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.
- **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.

Expand Down
100 changes: 94 additions & 6 deletions app/cli/internal/trace/attribution/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ import (
// CommitLines is one commit of a session: the lines it changed and the
// sessions that its record names.
type CommitLines struct {
SHA string
SessionIDs []string
Files []tracegit.FileLines
// Joined is set on a commit in no session that replaced lines of the
// session and so joined it (R-010). It holds, for each file, the
// fingerprints of the lines that its replacing hunks added. Only these
// lines count for the session, and they are human.
Joined map[string][]string
}

// Enrich adds AI/human attribution to the code changes of a session.
Expand All @@ -43,15 +49,31 @@ type CommitLines struct {
// another session of the commit counts for neither side: it is that session's
// work. All other lines are human.
//
// tip holds, for each file, its lines at the pushed commit with the commit
// that last wrote each one. The added lines of a file in tip are the net
// contribution of the session (R-003): the lines that its commits added and
// that no later commit replaced, each one counted once. A file that tip holds
// with no lines is gone, and adds nothing. A file that tip does not hold falls
// back to the sum of the added lines of each commit. Removed lines are always
// the sum over the session's own commits.
//
// A file with an edit recorded before line fingerprints existed keeps the
// earlier rule: AI lines are the recorded ranges, capped at the file's diff.
func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commits []CommitLines, changes *aicodingsession.CodeChanges) {
func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commits []CommitLines, tip map[string][]tracegit.BlameLine, changes *aicodingsession.CodeChanges) {
self := ledgers[sessionID]
if self == nil {
self = &state.AILineAttribution{SessionID: sessionID}
}

byPath := splitByPath(sessionID, self, ledgers, commits)
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
}

changes.AILinesAdded, changes.AILinesRemoved = 0, 0
changes.HumanLinesAdded, changes.HumanLinesRemoved = 0, 0
Expand Down Expand Up @@ -103,12 +125,12 @@ func splitByPath(sessionID string, self *state.AILineAttribution, ledgers map[st

out := make(map[string]*fileSplit)
for _, commit := range commits {
var others []*state.AILineAttribution
for _, id := range commit.SessionIDs {
if l := ledgers[id]; id != sessionID && l != nil {
others = append(others, l)
}
// A joined commit counts only through the lines that are still in
// the pushed commit (netAdded).
if commit.Joined != nil {
continue
}
others := otherLedgers(sessionID, ledgers, commit.SessionIDs)

for _, fl := range commit.Files {
s, ok := out[fl.Path]
Expand Down Expand Up @@ -143,6 +165,72 @@ func splitByPath(sessionID string, self *state.AILineAttribution, ledgers map[st
return out
}

// 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.
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 {
bySHA[c.SHA] = c
}

written := linePool{pick: func(a *state.AILineAttribution) map[string]state.LineCounts { return a.Written }}
joined := make(map[[2]string]state.LineCounts)

out := make(map[string]*fileSplit, len(tip))
for path, lines := range tip {
s := &fileSplit{}
out[path] = s

for _, l := range lines {
commit, ok := bySHA[l.SHA]
if !ok {
continue
}
fp := Fingerprint(l.Text)

if commit.Joined != nil {
key := [2]string{commit.SHA, path}
pool, ok := joined[key]
if !ok {
pool = state.LineCounts{}
pool.Add(commit.Joined[path]...)
joined[key] = pool
}
if pool.Take(fp) {
s.humanAdded++
}

continue
}

switch {
case written.take(self, path, fp):
s.aiAdded++
case written.takeAny(otherLedgers(sessionID, ledgers, commit.SessionIDs), path, fp):
default:
s.humanAdded++
}
}
}

return out
}

// otherLedgers returns the ledgers of the sessions in ids other than
// sessionID.
func otherLedgers(sessionID string, ledgers map[string]*state.AILineAttribution, ids []string) []*state.AILineAttribution {
var out []*state.AILineAttribution
for _, id := range ids {
if l := ledgers[id]; id != sessionID && l != nil {
out = append(out, l)
}
}

return out
}

// linePool hands out the lines of the ledgers, one copy per match. Each
// session's lines of a file are copied on first use and drawn down across
// the commits, so a line matches at most as often as the ledger holds it.
Expand Down
110 changes: 104 additions & 6 deletions app/cli/internal/trace/attribution/enrich_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package attribution

import (
"strings"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
Expand Down Expand Up @@ -67,7 +68,7 @@ func TestEnrich_Legacy(t *testing.T) {
},
}

Enrich(testSession, nil, nil, changes)
Enrich(testSession, nil, nil, nil, changes)

assert.Equal(t, 10, changes.HumanLinesAdded)
assert.Equal(t, 4, changes.HumanLinesRemoved)
Expand Down Expand Up @@ -95,7 +96,7 @@ func TestEnrich_Legacy(t *testing.T) {
},
}

Enrich(testSession, ledgers, nil, changes)
Enrich(testSession, ledgers, nil, nil, changes)

assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution)
assert.Equal(t, trace.AttributionAI, changes.Files[1].Attribution)
Expand All @@ -115,7 +116,7 @@ func TestEnrich_Legacy(t *testing.T) {
},
}

Enrich(testSession, ledgers, nil, changes)
Enrich(testSession, ledgers, nil, nil, changes)

assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution)
assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs)
Expand All @@ -141,7 +142,7 @@ func TestEnrich_Legacy(t *testing.T) {
},
}

Enrich(testSession, ledgers, nil, changes)
Enrich(testSession, ledgers, nil, nil, changes)

assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution)
assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs)
Expand All @@ -164,7 +165,7 @@ func TestEnrich_Legacy(t *testing.T) {
},
}

Enrich(testSession, ledgers, nil, changes)
Enrich(testSession, ledgers, nil, nil, changes)

assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution)
assert.Equal(t, trace.AttributionHuman, changes.Files[1].Attribution)
Expand Down Expand Up @@ -258,7 +259,7 @@ func TestEnrich_Lines(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
changes := &aicodingsession.CodeChanges{Files: []aicodingsession.FileChange{{Path: testFile}}}

Enrich(testSession, tc.ledgers, tc.commits, changes)
Enrich(testSession, tc.ledgers, tc.commits, nil, changes)

f := changes.Files[0]
assert.Equal(t, tc.want, split{f.AILinesAdded, f.AILinesRemoved, f.HumanLinesAdded, f.HumanLinesRemoved}, "file split")
Expand All @@ -270,6 +271,103 @@ func TestEnrich_Lines(t *testing.T) {
}
}

func TestEnrich_Net(t *testing.T) {
type split struct{ aiAdded, aiRemoved, humanAdded, humanRemoved int }

const c1, c2, c3 = "c1", "c2", "c3"
tipOf := func(lines ...string) []tracegit.BlameLine {
// Each entry is "sha:text".
out := make([]tracegit.BlameLine, 0, len(lines))
for _, l := range lines {
sha, text, _ := strings.Cut(l, ":")
out = append(out, tracegit.BlameLine{SHA: sha, Text: text})
}

return out
}
five := []string{"a", "b", "c", "d", "e"}
ownCommit := func(sha string, added, removed []string) CommitLines {
return CommitLines{SHA: sha, SessionIDs: []string{testSession}, Files: []tracegit.FileLines{{Path: testFile, Added: added, Removed: removed}}}
}

testCases := []struct {
name string
ledgers map[string]*state.AILineAttribution
commits []CommitLines
tip map[string][]tracegit.BlameLine
want split
}{
{
// Case 3 of the spec: a later commit of the session replaces an
// AI line by hand. Each line counts once (R-003).
name: "a replaced line of the session counts once",
ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, five, nil)},
commits: []CommitLines{
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},
},
{
// Case 2 of the spec: a commit in no session replaces an AI line
// and joins the session (R-010). Its replacing lines are human,
// and its removals of the session's lines do not count again.
name: "a joined commit adds human lines of its replacing hunks",
ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, five, nil)},
commits: []CommitLines{
ownCommit(c1, five, nil),
{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},
},
{
// Case 4 of the spec: a commit of another session replaces the
// line. The session keeps only what is still there.
name: "lines that a later commit outside the session replaced are gone",
ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, five, nil)},
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},
},
{
name: "a line of another session of the commit counts for neither",
ledgers: map[string]*state.AILineAttribution{
testSession: ledger(testSession, testFile, []string{"a"}, nil),
otherSession: ledger(otherSession, testFile, []string{"b"}, nil),
},
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},
},
{
name: "a file that the pushed commit does not hold adds nothing",
ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, five, []string{"z"})},
commits: []CommitLines{
ownCommit(c1, five, nil),
ownCommit(c2, nil, append([]string{"z"}, five...)),
},
tip: map[string][]tracegit.BlameLine{testFile: nil},
want: split{aiRemoved: 1, humanRemoved: 5},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
changes := &aicodingsession.CodeChanges{Files: []aicodingsession.FileChange{{Path: testFile}}}

Enrich(testSession, tc.ledgers, tc.commits, tc.tip, changes)

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})
})
}
}

func TestSettleCommit(t *testing.T) {
open := func(added, removed []string) *state.OpenLines {
o := &state.OpenLines{Added: state.LineCounts{}, Removed: state.LineCounts{}}
Expand Down
7 changes: 6 additions & 1 deletion app/cli/internal/trace/attribution/generated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package attribution

import (
"os"
"os/exec"
"path/filepath"
"testing"

Expand Down Expand Up @@ -83,6 +84,10 @@ func TestFilterGenerated(t *testing.T) {

t.Run("ent gitattributes rules drop generated ent code but keep schema and migrations", func(t *testing.T) {
dir := t.TempDir()
// The exec client reads attributes with `git check-attr`, which needs
// a repository.
out, err := exec.Command("git", "init", "-q", dir).CombinedOutput()
require.NoError(t, err, string(out))
// Order matches the project .gitattributes: general rule first, then
// more-specific overrides (last match wins, per gitattributes spec).
writeGitAttributes(t, dir,
Expand All @@ -91,7 +96,7 @@ func TestFilterGenerated(t *testing.T) {
"backend/internal/data/ent/schema/* linguist-generated=false\n",
)

isGenerated := tracegit.NewGoGitClient().GeneratedMatcher(dir)
isGenerated := tracegit.NewGitClient().GeneratedMatcher(dir)

changes := &aicodingsession.CodeChanges{
FilesCreated: 3,
Expand Down
Loading
Loading