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
2 changes: 1 addition & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ 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 | No | No |
| 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 | No | No |
| 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 | 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 | 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 removals of the session's commits, less the lines that a commit of the session wrote. The line counts of each file are the sum of its AI and human lines, and a file that the session created stays created when a later commit of the session changes it (spec issue-3639, issue 3674). | Partial | Partial | Partial | Partial | No | 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 | 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 | 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 | No |
Expand Down
14 changes: 12 additions & 2 deletions app/cli/internal/trace/attribution/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ type CommitLines struct {
// 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.
// back to the sum of the added lines of each commit. Removed lines are the sum
// over the session's own commits; each commit holds only the removed lines
// that no earlier commit of the session wrote. The line counts of a file with
// a split are the sum of the split, so the AI and human numbers add up to them
// (issue 3674).
//
// 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,
Expand Down Expand Up @@ -101,6 +104,7 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
}
}

changes.LinesAdded, changes.LinesRemoved = 0, 0
changes.AILinesAdded, changes.AILinesRemoved = 0, 0
changes.HumanLinesAdded, changes.HumanLinesRemoved = 0, 0
for i := range changes.Files {
Expand All @@ -127,6 +131,10 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
case ok:
f.AILinesAdded, f.AILinesRemoved = s.aiAdded, s.aiRemoved
f.HumanLinesAdded, f.HumanLinesRemoved = s.humanAdded, s.humanRemoved
// The line counts of the file are the net numbers of the
// split, not the sum of the diff of each commit.
f.LinesAdded = s.aiAdded + s.humanAdded
Comment thread
jiparis marked this conversation as resolved.
f.LinesRemoved = s.aiRemoved + s.humanRemoved
case f.Attribution == trace.AttributionHuman:
// No line content for the file (a binary file, or a commit that
// could not be read): the diff totals are human, as before.
Expand All @@ -135,6 +143,8 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi
legacySplit(f, self.Files[f.Path])
}

changes.LinesAdded += f.LinesAdded
changes.LinesRemoved += f.LinesRemoved
changes.AILinesAdded += f.AILinesAdded
changes.AILinesRemoved += f.AILinesRemoved
changes.HumanLinesAdded += f.HumanLinesAdded
Expand Down
15 changes: 12 additions & 3 deletions app/cli/internal/trace/attribution/enrich_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,17 @@ func TestEnrich_Net(t *testing.T) {
}{
{
// Case 3 of the spec: a later commit of the session replaces an
// AI line by hand. Each line counts once (R-003).
// AI line by hand. Each line counts once (R-003). The replaced
// line is the session's own, so c2 does not hold it as removed
// (sessionCommitLines leaves it out).
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"}),
ownCommit(c2, []string{"C"}, nil),
},
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},
want: split{aiAdded: 4, humanAdded: 1},
ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)},
},
{
Expand Down Expand Up @@ -450,6 +452,13 @@ func TestEnrich_Net(t *testing.T) {
assert.Equal(t, tc.want, split{changes.AILinesAdded, changes.AILinesRemoved, changes.HumanLinesAdded, changes.HumanLinesRemoved})
assert.Equal(t, tc.ranges, f.LineRanges)

// The line counts of the file and of the session are the sum of
// the split (issue 3674).
assert.Equal(t, tc.want.aiAdded+tc.want.humanAdded, f.LinesAdded, "lines added of the file")
assert.Equal(t, tc.want.aiRemoved+tc.want.humanRemoved, f.LinesRemoved, "lines removed of the file")
assert.Equal(t, f.LinesAdded, changes.LinesAdded, "lines added of the session")
assert.Equal(t, f.LinesRemoved, changes.LinesRemoved, "lines removed of the session")

// R-003: the ranges hold as many lines as the net added lines.
covered := map[string]int{}
for _, r := range f.LineRanges {
Expand Down
7 changes: 5 additions & 2 deletions app/cli/internal/trace/git/aggregate.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import (

// fileAgg is the running per-path total used while accumulating diffs from a
// set of commits. Backends share it so the aggregation rule (sum lines,
// last-wins status) lives in one place.
// last-wins status, except that a created file stays created) lives in one
// place.
type fileAgg struct {
linesAdded int
linesRemoved int
Expand All @@ -43,7 +44,9 @@ func addCommitStats(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg
}
agg.linesAdded += f.LinesAdded
agg.linesRemoved += f.LinesRemoved
if f.Status != "" {
// A file that an earlier commit created stays created, unless a
// later commit deletes it.
if f.Status != "" && (agg.status != StatusCreated || f.Status == StatusDeleted) {
agg.status = f.Status
}
}
Expand Down
49 changes: 49 additions & 0 deletions app/cli/internal/trace/git/aggregate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package git

import (
"testing"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
"github.com/stretchr/testify/assert"
)

func TestAddCommitStats_Status(t *testing.T) {
testCases := []struct {
name string
statuses []string
want string
}{
{name: "one commit", statuses: []string{StatusModified}, want: StatusModified},
{name: "a file that the commits create stays created", statuses: []string{StatusCreated, StatusModified, StatusModified}, want: StatusCreated},
{name: "a created file that a later commit deletes is deleted", statuses: []string{StatusCreated, StatusDeleted}, want: StatusDeleted},
{name: "the last status wins otherwise", statuses: []string{StatusModified, StatusDeleted}, want: StatusDeleted},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := &aicodingsession.CodeChanges{}
byPath := make(map[string]*fileAgg)
for _, s := range tc.statuses {
addCommitStats(out, byPath, &aicodingsession.CodeChanges{Files: []aicodingsession.FileChange{{Path: "a.txt", Status: s}}})
}
finalizeAggregatedFiles(out, byPath)

assert.Equal(t, tc.want, out.Files[0].Status)
})
}
}
3 changes: 3 additions & 0 deletions app/cli/internal/trace/git/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ func TestExecClient_CodeChangesForCommits(t *testing.T) {
assert.Equal(t, "a.txt", changes.Files[0].Path)
assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C")
assert.Equal(t, 3, changes.LinesAdded)
assert.Equal(t, StatusCreated, changes.Files[0].Status, "commit-A created the file")
assert.Equal(t, 1, changes.FilesCreated)
assert.Equal(t, 0, changes.FilesModified)
})

t.Run("empty input returns empty result", func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions app/cli/internal/trace/git/gogit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ func TestGoGitClient_CodeChangesForCommits(t *testing.T) {
assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C")
assert.Equal(t, 0, changes.Files[0].LinesRemoved)
assert.Equal(t, 3, changes.LinesAdded)
assert.Equal(t, StatusCreated, changes.Files[0].Status, "commit-A created the file")
assert.Equal(t, 1, changes.FilesCreated)
assert.Equal(t, 0, changes.FilesModified)
})

t.Run("empty input returns empty result", func(t *testing.T) {
Expand Down
Loading
Loading