diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index fa8d84a1d..9bcb0d803 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -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 | +| 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 diff --git a/app/cli/internal/trace/attribution/diff.go b/app/cli/internal/trace/attribution/diff.go index 0e0f7c846..7b9a41045 100644 --- a/app/cli/internal/trace/attribution/diff.go +++ b/app/cli/internal/trace/attribution/diff.go @@ -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" @@ -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. diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go index a056dd845..0d5fed4ae 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -17,44 +17,260 @@ package attribution import ( "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" ) -// Enrich adds AI/human attribution to code changes based on AI line range data. -// The sessionID identifies which session produced the changes, and aiFiles maps -// file paths to the line ranges modified by the AI in that session. -func Enrich(sessionID string, aiFiles map[string][]aicodingsession.LineRange, changes *aicodingsession.CodeChanges) { - if len(aiFiles) == 0 { - // No AI line data — all changes are human - changes.HumanLinesAdded = changes.LinesAdded - changes.HumanLinesRemoved = changes.LinesRemoved - for i := range changes.Files { - changes.Files[i].Attribution = trace.AttributionHuman - } +// CommitLines is one commit of a session: the lines it changed and the +// sessions that its record names. +type CommitLines struct { + SessionIDs []string + Files []tracegit.FileLines +} - return +// Enrich adds AI/human attribution to the code changes of a session. +// +// ledgers holds the AI line ledger of each session by ID. commits holds the +// session's commits, oldest first. Each line that a commit added is AI when its +// content matches a line that an edit of the session wrote (R-001), and each +// removed line is AI when an edit of the session removed it (R-005). Each +// ledger line matches one commit line at most. +// +// The session's own ledger is matched first. When it and another session of +// the commit both hold a line, the evidence of each session counts the line as +// its own: each evidence is the view of one session. A line that matches only +// another session of the commit counts for neither side: it is that session's +// work. All other lines are human. +// +// 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) { + self := ledgers[sessionID] + if self == nil { + self = &state.AILineAttribution{SessionID: sessionID} } + byPath := splitByPath(sessionID, self, ledgers, commits) + + changes.AILinesAdded, changes.AILinesRemoved = 0, 0 + changes.HumanLinesAdded, changes.HumanLinesRemoved = 0, 0 for i := range changes.Files { f := &changes.Files[i] - // An empty ranges slice is valid: the agent only deleted lines. - if aiRanges, ok := aiFiles[f.Path]; ok { + + // The marker keeps its earlier meaning, a file that the session + // edited, for platforms that do not read the split (R-009). An empty + // ranges slice is valid: the agent only deleted lines. + if aiRanges, ok := self.Files[f.Path]; ok { f.Attribution = trace.AttributionAI f.LineRanges = aiRanges f.SessionIDs = []string{sessionID} - // Count AI lines as the lines covered by AI ranges, capped at the file's diff totals - aiLineCount := 0 - for _, r := range aiRanges { - aiLineCount += r.End - r.Start + 1 - } - aiAdded := min(aiLineCount, f.LinesAdded) - changes.AILinesAdded += aiAdded - changes.HumanLinesAdded += f.LinesAdded - aiAdded - changes.AILinesRemoved += f.LinesRemoved } else { f.Attribution = trace.AttributionHuman - changes.HumanLinesAdded += f.LinesAdded - changes.HumanLinesRemoved += f.LinesRemoved } + + switch s, ok := byPath[f.Path]; { + case self.Legacy[f.Path]: + legacySplit(f, self.Files[f.Path]) + case ok: + f.AILinesAdded, f.AILinesRemoved = s.aiAdded, s.aiRemoved + f.HumanLinesAdded, f.HumanLinesRemoved = s.humanAdded, 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. + f.HumanLinesAdded, f.HumanLinesRemoved = f.LinesAdded, f.LinesRemoved + default: + legacySplit(f, self.Files[f.Path]) + } + + changes.AILinesAdded += f.AILinesAdded + changes.AILinesRemoved += f.AILinesRemoved + changes.HumanLinesAdded += f.HumanLinesAdded + changes.HumanLinesRemoved += f.HumanLinesRemoved + } +} + +// fileSplit is the AI/human split of the changed lines of one file. +type fileSplit struct { + aiAdded, aiRemoved, humanAdded, humanRemoved int +} + +// splitByPath matches the lines of each commit against the ledgers, and +// returns the split of each file that the commits changed. +func splitByPath(sessionID string, self *state.AILineAttribution, ledgers map[string]*state.AILineAttribution, commits []CommitLines) map[string]*fileSplit { + written := linePool{pick: func(a *state.AILineAttribution) map[string]state.LineCounts { return a.Written }} + erased := linePool{pick: func(a *state.AILineAttribution) map[string]state.LineCounts { return a.Erased }} + + 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) + } + } + + for _, fl := range commit.Files { + s, ok := out[fl.Path] + if !ok { + s = &fileSplit{} + out[fl.Path] = s + } + + for _, line := range fl.Added { + fp := Fingerprint(line) + switch { + case written.take(self, fl.Path, fp): + s.aiAdded++ + case written.takeAny(others, fl.Path, fp): + default: + s.humanAdded++ + } + } + for _, line := range fl.Removed { + fp := Fingerprint(line) + switch { + case erased.take(self, fl.Path, fp): + s.aiRemoved++ + case erased.takeAny(others, fl.Path, fp): + default: + s.humanRemoved++ + } + } + } + } + + 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. +type linePool struct { + // pick selects the lines of a ledger that the pool draws from. + pick func(*state.AILineAttribution) map[string]state.LineCounts + counts map[[2]string]state.LineCounts +} + +// take takes one copy of fp from the lines of ledger l for path. +func (p *linePool) take(l *state.AILineAttribution, path, fp string) bool { + if p.counts == nil { + p.counts = make(map[[2]string]state.LineCounts) + } + + key := [2]string{l.SessionID, path} + c, ok := p.counts[key] + if !ok { + c = p.pick(l)[path].Clone() + p.counts[key] = c + } + + return c.Take(fp) +} + +// takeAny takes fp from the first ledger that holds it. +func (p *linePool) takeAny(ledgers []*state.AILineAttribution, path, fp string) bool { + for _, l := range ledgers { + if p.take(l, path, fp) { + return true + } + } + + return false +} + +// legacySplit is the split of a file from its recorded ranges: the lines that +// the ranges cover are AI, capped at the file's diff, and every removal is AI. +func legacySplit(f *aicodingsession.FileChange, aiRanges []aicodingsession.LineRange) { + aiLineCount := 0 + for _, r := range aiRanges { + aiLineCount += r.End - r.Start + 1 + } + f.AILinesAdded = min(aiLineCount, f.LinesAdded) + f.HumanLinesAdded = f.LinesAdded - f.AILinesAdded + f.AILinesRemoved = f.LinesRemoved + f.HumanLinesRemoved = 0 +} + +// SettleCommit returns the open lines of one file that a commit closes: the +// open lines that the commit added or removed, and the open added lines that +// the worktree no longer holds, because something other than the session +// changed them (R-002). The other open lines stay open for a later commit +// (R-006). When clean is true, the worktree holds the file as the commit +// does, so nothing of the file is left to commit and all of it closes. +func SettleCommit(open *state.OpenLines, committed tracegit.FileLines, worktree []byte, clean bool) state.LineSet { + var out state.LineSet + if open == nil { + return out + } + + added, removed := open.Added.Clone(), open.Removed.Clone() + for _, line := range committed.Added { + if fp := Fingerprint(line); added.Take(fp) { + out.Added = append(out.Added, fp) + } + } + for _, line := range committed.Removed { + if fp := Fingerprint(line); removed.Take(fp) { + out.Removed = append(out.Removed, fp) + } + } + + if clean { + out.Added = append(out.Added, added.Missing(nil)...) + out.Removed = append(out.Removed, removed.Missing(nil)...) + + return out } + + present := state.LineCounts{} + present.Add(FingerprintLines(worktree)...) + out.Added = append(out.Added, added.Missing(present)...) + + return out +} + +// DropOpen removes from an edit the lines that are already open for the +// session, and lowers its counts by the same amount. A shell command is diffed +// against HEAD, not against the session's last edit, so its diff holds again +// the uncommitted lines that earlier edits recorded. A command that writes one +// more copy of an open line loses that copy too; the ledger cannot tell the +// two apart. +func DropOpen(edit Edit, open *state.OpenLines) Edit { + if open == nil { + return edit + } + + keep := func(fps []string, openCounts state.LineCounts) []string { + left := openCounts.Clone() + var out []string + for _, fp := range fps { + if !left.Take(fp) { + out = append(out, fp) + } + } + + return out + } + + added, removed := keep(edit.Added, open.Added), keep(edit.Removed, open.Removed) + edit.AddedCount -= len(edit.Added) - len(added) + edit.RemovedCount -= len(edit.Removed) - len(removed) + edit.Added, edit.Removed = added, removed + + return edit +} + +// ReplacedLines returns the open added lines of a file that its current +// content no longer holds. Before an agent edit, these are the lines that a +// human or another session changed since the session's last edit (R-002). +func ReplacedLines(open *state.OpenLines, current []byte) state.LineSet { + if open == nil || len(open.Added) == 0 { + return state.LineSet{} + } + + present := state.LineCounts{} + present.Add(FingerprintLines(current)...) + + return state.LineSet{Added: open.Added.Missing(present)} } diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go index d926d785d..745a8fae3 100644 --- a/app/cli/internal/trace/attribution/enrich_test.go +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -19,27 +19,63 @@ import ( "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" "github.com/stretchr/testify/assert" ) -func TestEnrich(t *testing.T) { +const ( + testSession = "sess" + otherSession = "other" + testFile = "a.go" +) + +// legacyLedger is the ledger of a session whose edits were recorded before +// line fingerprints existed: ranges only. +func legacyLedger(sessionID string, files map[string][]aicodingsession.LineRange) *state.AILineAttribution { + attr := &state.AILineAttribution{SessionID: sessionID, Files: files, Legacy: map[string]bool{}} + for f := range files { + attr.Legacy[f] = true + } + + return attr +} + +// ledger is the ledger of a session that wrote and erased the given lines +// of one file. +func ledger(sessionID, file string, written, erased []string) *state.AILineAttribution { + w, e := state.LineCounts{}, state.LineCounts{} + w.Add(fps(written...)...) + e.Add(fps(erased...)...) + + return &state.AILineAttribution{ + SessionID: sessionID, + Files: map[string][]aicodingsession.LineRange{file: nil}, + Written: map[string]state.LineCounts{file: w}, + Erased: map[string]state.LineCounts{file: e}, + } +} + +func TestEnrich_Legacy(t *testing.T) { t.Run("no AI data attributes all changes to human", func(t *testing.T) { changes := &aicodingsession.CodeChanges{ LinesAdded: 10, LinesRemoved: 4, Files: []aicodingsession.FileChange{ - {Path: "a.go", LinesAdded: 10, LinesRemoved: 4}, + {Path: testFile, LinesAdded: 10, LinesRemoved: 4}, }, } - Enrich("sess", nil, changes) + Enrich(testSession, nil, nil, changes) assert.Equal(t, 10, changes.HumanLinesAdded) assert.Equal(t, 4, changes.HumanLinesRemoved) assert.Equal(t, 0, changes.AILinesAdded) assert.Equal(t, 0, changes.AILinesRemoved) assert.Equal(t, trace.AttributionHuman, changes.Files[0].Attribution) + assert.Equal(t, 10, changes.Files[0].HumanLinesAdded) + assert.Equal(t, 4, changes.Files[0].HumanLinesRemoved) }) t.Run("shell-generated file recorded as AI is not counted as human (PFM-6684)", func(t *testing.T) { @@ -47,10 +83,10 @@ func TestEnrich(t *testing.T) { // command, once recorded in the AI-line map (whole-file range), must be // attributed to AI — not dumped into the human bucket where it drowns // the small edits and yields a misleading 0% AI. - aiFiles := map[string][]aicodingsession.LineRange{ + ledgers := map[string]*state.AILineAttribution{testSession: legacyLedger(testSession, map[string][]aicodingsession.LineRange{ "policies/foo.rego": {{Start: 1, End: 222}}, "policies/integration-tests/materials/sbom.cyclonedx.json": {{Start: 1, End: 177764}}, - } + })} changes := &aicodingsession.CodeChanges{ LinesAdded: 177986, Files: []aicodingsession.FileChange{ @@ -59,7 +95,7 @@ func TestEnrich(t *testing.T) { }, } - Enrich("sess", aiFiles, changes) + Enrich(testSession, ledgers, nil, changes) assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) assert.Equal(t, trace.AttributionAI, changes.Files[1].Attribution) @@ -68,65 +104,67 @@ func TestEnrich(t *testing.T) { }) t.Run("file with AI ranges is attributed to AI", func(t *testing.T) { - aiFiles := map[string][]aicodingsession.LineRange{ - "a.go": {{Start: 1, End: 5}}, - } + ledgers := map[string]*state.AILineAttribution{testSession: legacyLedger(testSession, map[string][]aicodingsession.LineRange{ + testFile: {{Start: 1, End: 5}}, + })} changes := &aicodingsession.CodeChanges{ LinesAdded: 5, LinesRemoved: 2, Files: []aicodingsession.FileChange{ - {Path: "a.go", LinesAdded: 5, LinesRemoved: 2}, + {Path: testFile, LinesAdded: 5, LinesRemoved: 2}, }, } - Enrich("sess", aiFiles, changes) + Enrich(testSession, ledgers, nil, changes) assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) - assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs) assert.Equal(t, 5, changes.AILinesAdded) assert.Equal(t, 2, changes.AILinesRemoved) assert.Equal(t, 0, changes.HumanLinesAdded) assert.Equal(t, 0, changes.HumanLinesRemoved) + assert.Equal(t, 5, changes.Files[0].AILinesAdded) + assert.Equal(t, 2, changes.Files[0].AILinesRemoved) }) t.Run("deletion-only file with empty ranges is attributed to AI", func(t *testing.T) { // AI removed lines from a file — there are no "after" ranges, but // the file is still listed in the session's attribution map. - aiFiles := map[string][]aicodingsession.LineRange{ - "a.go": nil, - } + ledgers := map[string]*state.AILineAttribution{testSession: legacyLedger(testSession, map[string][]aicodingsession.LineRange{ + testFile: nil, + })} changes := &aicodingsession.CodeChanges{ LinesAdded: 0, LinesRemoved: 4, Files: []aicodingsession.FileChange{ - {Path: "a.go", LinesAdded: 0, LinesRemoved: 4}, + {Path: testFile, LinesAdded: 0, LinesRemoved: 4}, }, } - Enrich("sess", aiFiles, changes) + Enrich(testSession, ledgers, nil, changes) assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) - assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs) assert.Equal(t, 0, changes.AILinesAdded) assert.Equal(t, 4, changes.AILinesRemoved) assert.Equal(t, 0, changes.HumanLinesAdded) assert.Equal(t, 0, changes.HumanLinesRemoved) }) - t.Run("files not in aiFiles are attributed to human", func(t *testing.T) { - aiFiles := map[string][]aicodingsession.LineRange{ - "a.go": {{Start: 1, End: 2}}, - } + t.Run("files not in the ledger are attributed to human", func(t *testing.T) { + ledgers := map[string]*state.AILineAttribution{testSession: legacyLedger(testSession, map[string][]aicodingsession.LineRange{ + testFile: {{Start: 1, End: 2}}, + })} changes := &aicodingsession.CodeChanges{ LinesAdded: 6, LinesRemoved: 3, Files: []aicodingsession.FileChange{ - {Path: "a.go", LinesAdded: 2, LinesRemoved: 1}, + {Path: testFile, LinesAdded: 2, LinesRemoved: 1}, {Path: "b.go", LinesAdded: 4, LinesRemoved: 2}, }, } - Enrich("sess", aiFiles, changes) + Enrich(testSession, ledgers, nil, changes) assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) assert.Equal(t, trace.AttributionHuman, changes.Files[1].Attribution) @@ -136,3 +174,187 @@ func TestEnrich(t *testing.T) { assert.Equal(t, 2, changes.HumanLinesRemoved) }) } + +func TestEnrich_Lines(t *testing.T) { + type split struct{ aiAdded, aiRemoved, humanAdded, humanRemoved int } + + testCases := []struct { + name string + ledgers map[string]*state.AILineAttribution + commits []CommitLines + want split + }{ + { + // R-001: the AI writes five lines, and a human changes one of them + // before the commit. + name: "a human change to an AI line is human", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, []string{"a", "b", "c", "d", "e"}, nil)}, + commits: []CommitLines{{SessionIDs: []string{testSession}, Files: []tracegit.FileLines{ + {Path: testFile, Added: []string{"a", "b", "C", "d", "e"}}, + }}}, + want: split{aiAdded: 4, humanAdded: 1}, + }, + { + name: "a re-indented AI line stays AI", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, []string{"\tfoo()"}, nil)}, + commits: []CommitLines{{SessionIDs: []string{testSession}, Files: []tracegit.FileLines{ + {Path: testFile, Added: []string{" foo()"}}, + }}}, + want: split{aiAdded: 1}, + }, + { + name: "a repeated line counts as AI only as often as the AI wrote it", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, []string{"}", "}"}, nil)}, + commits: []CommitLines{{SessionIDs: []string{testSession}, Files: []tracegit.FileLines{ + {Path: testFile, Added: []string{"}", "}", "}"}}, + }}}, + want: split{aiAdded: 2, humanAdded: 1}, + }, + { + // R-005: only the lines that an agent edit removed are AI removals. + name: "removals count for the remover", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, nil, []string{"x"})}, + commits: []CommitLines{{SessionIDs: []string{testSession}, Files: []tracegit.FileLines{ + {Path: testFile, Removed: []string{"x", "y"}}, + }}}, + want: split{aiRemoved: 1, humanRemoved: 1}, + }, + { + name: "lines of another session of the commit count for neither", + ledgers: map[string]*state.AILineAttribution{ + testSession: ledger(testSession, testFile, []string{"a"}, nil), + otherSession: ledger(otherSession, testFile, []string{"b"}, []string{"x"}), + }, + commits: []CommitLines{{SessionIDs: []string{testSession, otherSession}, Files: []tracegit.FileLines{ + {Path: testFile, Added: []string{"a", "b", "c"}, Removed: []string{"x"}}, + }}}, + want: split{aiAdded: 1, humanAdded: 1}, + }, + { + name: "a session not named by the commit does not own its lines", + ledgers: map[string]*state.AILineAttribution{ + testSession: ledger(testSession, testFile, []string{"a"}, nil), + otherSession: ledger(otherSession, testFile, []string{"b"}, nil), + }, + commits: []CommitLines{{SessionIDs: []string{testSession}, Files: []tracegit.FileLines{ + {Path: testFile, Added: []string{"a", "b"}}, + }}}, + want: split{aiAdded: 1, humanAdded: 1}, + }, + { + // Each commit of the session adds to the split. Milestone 2 of the + // spec replaces this sum with the net contribution (R-003). + name: "commits of the session add up", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, []string{"a", "b"}, nil)}, + commits: []CommitLines{ + {SessionIDs: []string{testSession}, Files: []tracegit.FileLines{{Path: testFile, Added: []string{"a", "b"}}}}, + {SessionIDs: []string{testSession}, Files: []tracegit.FileLines{{Path: testFile, Added: []string{"B"}, Removed: []string{"b"}}}}, + }, + want: split{aiAdded: 2, humanAdded: 1, humanRemoved: 1}, + }, + } + + 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, changes) + + f := changes.Files[0] + assert.Equal(t, tc.want, split{f.AILinesAdded, f.AILinesRemoved, f.HumanLinesAdded, f.HumanLinesRemoved}, "file split") + assert.Equal(t, tc.want, split{changes.AILinesAdded, changes.AILinesRemoved, changes.HumanLinesAdded, changes.HumanLinesRemoved}, + "session totals are the sum of the files") + assert.Equal(t, trace.AttributionAI, f.Attribution, "the marker for older platforms still names the files that the session edited") + assert.Equal(t, []string{testSession}, f.SessionIDs) + }) + } +} + +func TestSettleCommit(t *testing.T) { + open := func(added, removed []string) *state.OpenLines { + o := &state.OpenLines{Added: state.LineCounts{}, Removed: state.LineCounts{}} + o.Added.Add(fps(added...)...) + o.Removed.Add(fps(removed...)...) + + return o + } + + testCases := []struct { + name string + open *state.OpenLines + committed tracegit.FileLines + worktree string + clean bool + want state.LineSet + }{ + { + name: "the commit takes the open lines it holds (R-006)", + open: open([]string{"a", "b"}, []string{"x", "y"}), + committed: tracegit.FileLines{Added: []string{"a", "h"}, Removed: []string{"x"}}, + worktree: "a\nb\n", + want: state.LineSet{Added: fps("a"), Removed: fps("x")}, + }, + { + // R-002: a human changed b before the commit. The line is gone, so + // it cannot reach a later commit. + name: "open lines gone from the worktree close", + open: open([]string{"a", "b"}, nil), + committed: tracegit.FileLines{Added: []string{"a", "B"}}, + worktree: "a\nB\n", + want: state.LineSet{Added: fps("a", "b")}, + }, + { + name: "a fully committed file closes all its open lines", + open: open([]string{"a", "b"}, []string{"x"}), + committed: tracegit.FileLines{Added: []string{"a"}}, + worktree: "a\nb\n", + clean: true, + want: state.LineSet{Added: fps("a", "b"), Removed: fps("x")}, + }, + { + name: "nothing open", + committed: tracegit.FileLines{Added: []string{"a"}}, + worktree: "a\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := SettleCommit(tc.open, tc.committed, []byte(tc.worktree), tc.clean) + assert.ElementsMatch(t, tc.want.Added, got.Added) + assert.ElementsMatch(t, tc.want.Removed, got.Removed) + }) + } +} + +func TestDropOpen(t *testing.T) { + o := &state.OpenLines{Added: state.LineCounts{}, Removed: state.LineCounts{}} + o.Added.Add(fps("a", "b")...) + o.Removed.Add(fps("x")...) + + got := DropOpen(Edit{ + Changed: true, + Added: fps("a", "b", "c"), + Removed: fps("x", "y"), + AddedCount: 3, + RemovedCount: 2, + }, o) + + assert.ElementsMatch(t, fps("c"), got.Added) + assert.ElementsMatch(t, fps("y"), got.Removed) + assert.Equal(t, 1, got.AddedCount, "lines recorded before are not generated again") + assert.Equal(t, 1, got.RemovedCount) + assert.True(t, got.Changed) + assert.Equal(t, 2, o.Added.Len(), "the open lines are not changed") + + assert.Equal(t, fps("a"), DropOpen(Edit{Added: fps("a")}, nil).Added) +} + +func TestReplacedLines(t *testing.T) { + o := &state.OpenLines{Added: state.LineCounts{}, Removed: state.LineCounts{}} + o.Added.Add(fps("a", "b", "b")...) + + assert.ElementsMatch(t, fps("b"), ReplacedLines(o, []byte("a\nb\nB\n")).Added) + assert.Empty(t, ReplacedLines(o, []byte("b\nb\na\n")).Added) + assert.True(t, ReplacedLines(nil, []byte("a\n")).IsEmpty()) +} diff --git a/app/cli/internal/trace/attribution/lines.go b/app/cli/internal/trace/attribution/lines.go new file mode 100644 index 000000000..8b87e9b47 --- /dev/null +++ b/app/cli/internal/trace/attribution/lines.go @@ -0,0 +1,71 @@ +// +// 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 attribution + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "unicode" +) + +// Fingerprint returns the content key of one line. Whitespace is dropped +// before hashing, so a line that a change only re-indented keeps its key, and +// with it its owner (D-008). +func Fingerprint(line string) string { + stripped := strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + + return r + }, line) + + sum := sha256.Sum256([]byte(stripped)) + + // 8 bytes keep the ledger small. A collision only matters between lines + // of the same file, where it is negligible. + return hex.EncodeToString(sum[:8]) +} + +// FingerprintLines returns the fingerprint of each line of content, in order. +func FingerprintLines(content []byte) []string { + lines := splitLines(string(content)) + out := make([]string, 0, len(lines)) + for _, l := range lines { + out = append(out, Fingerprint(l)) + } + + return out +} + +// splitLines splits text into lines without their line ends. A final line +// with no newline is a line too, and empty text has no lines. +func splitLines(text string) []string { + if text == "" { + return nil + } + + lines := strings.SplitAfter(text, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + for i, l := range lines { + lines[i] = strings.TrimSuffix(l, "\n") + } + + return lines +} diff --git a/app/cli/internal/trace/attribution/lines_test.go b/app/cli/internal/trace/attribution/lines_test.go new file mode 100644 index 000000000..1a77911b7 --- /dev/null +++ b/app/cli/internal/trace/attribution/lines_test.go @@ -0,0 +1,139 @@ +// +// 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 attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" +) + +func TestFingerprint(t *testing.T) { + const returnNil = "return nil" + + testCases := []struct { + name string + a, b string + equal bool + }{ + {name: "same content", a: returnNil, b: returnNil, equal: true}, + {name: "re-indented", a: "\treturn nil", b: " return nil", equal: true}, + {name: "inner whitespace", a: "x := 1", b: "x:=1", equal: true}, + {name: "carriage return", a: "x := 1\r", b: "x := 1", equal: true}, + {name: "different content", a: returnNil, b: "return err", equal: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.equal, Fingerprint(tc.a) == Fingerprint(tc.b)) + }) + } +} + +func fps(lines ...string) []string { + out := make([]string, 0, len(lines)) + for _, l := range lines { + out = append(out, Fingerprint(l)) + } + + return out +} + +func TestComputeEdit(t *testing.T) { + testCases := []struct { + name string + before []byte + after []byte + changed bool + ranges []aicodingsession.LineRange + added []string + removed []string + addedCount int + removedCount int + }{ + { + name: "identical content", + after: []byte("a\n"), before: []byte("a\n"), + }, + { + name: "new file", + after: []byte("a\nb\nc"), + changed: true, + ranges: []aicodingsession.LineRange{{Start: 1, End: 3}}, + added: fps("a", "b", "c"), + addedCount: 3, + }, + { + name: "modified line", + before: []byte("a\nb\nc\n"), + after: []byte("a\nB\nc\n"), + changed: true, + ranges: []aicodingsession.LineRange{{Start: 2, End: 2}}, + added: fps("B"), + removed: fps("b"), + addedCount: 1, + removedCount: 1, + }, + { + name: "deleted file", + before: []byte("a\nb\n"), + changed: true, + removed: fps("a", "b"), + removedCount: 2, + }, + { + // A re-indent writes no new code, so the line keeps its owner + // (D-008). The raw counts still record what the edit touched. + name: "whitespace-only change", + before: []byte("if x {\nreturn\n}\n"), + after: []byte("if x {\n\treturn\n}\n"), + changed: true, + ranges: []aicodingsession.LineRange{{Start: 2, End: 2}}, + addedCount: 1, + removedCount: 1, + }, + { + name: "re-indent next to a real change", + before: []byte("a\nb\n"), + after: []byte(" a\nc\n"), + changed: true, + ranges: []aicodingsession.LineRange{{Start: 1, End: 2}}, + added: fps("c"), + removed: fps("b"), + addedCount: 2, + removedCount: 2, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := ComputeEdit(tc.before, tc.after) + assert.Equal(t, tc.changed, got.Changed) + assert.Equal(t, tc.ranges, got.Ranges) + assert.ElementsMatch(t, tc.added, got.Added) + assert.ElementsMatch(t, tc.removed, got.Removed) + assert.Equal(t, tc.addedCount, got.AddedCount) + assert.Equal(t, tc.removedCount, got.RemovedCount) + }) + } +} + +func TestFingerprintLines(t *testing.T) { + assert.Equal(t, fps("a", "b"), FingerprintLines([]byte("a\nb\n"))) + assert.Equal(t, fps("a", "b"), FingerprintLines([]byte("a\nb"))) + assert.Empty(t, FingerprintLines(nil)) +} diff --git a/app/cli/internal/trace/git/git.go b/app/cli/internal/trace/git/git.go index 0652ef80f..1b3a04c28 100644 --- a/app/cli/internal/trace/git/git.go +++ b/app/cli/internal/trace/git/git.go @@ -31,6 +31,14 @@ const ( StatusRenamed = "renamed" ) +// FileLines holds the lines that one commit added to and removed from a file, +// without their line ends. +type FileLines struct { + Path string + Added []string + Removed []string +} + // Client abstracts git operations so the implementation can be swapped // (e.g. from exec-based to go-git). type Client interface { @@ -48,6 +56,13 @@ type Client interface { // handled correctly. The order in which commits are visited is unspecified; // per-file Status reflects the most recently visited commit's view. CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) + // CommitLines returns, for each file that the commit changed against its + // first parent, the content of the lines it added and removed. Binary + // files are left out. Renames show as a removal and an addition. + CommitLines(repoRoot, sha string) ([]FileLines, error) + // FileAt returns the content of a repo-relative path at a commit, or nil + // when the commit does not hold the path. + FileAt(repoRoot, sha, path string) ([]byte, error) // CommitHeadInfo returns the SHA and message of HEAD. CommitHeadInfo(repoRoot string) (sha, message string, err error) // HeadAuthorTime returns the author date of HEAD. diff --git a/app/cli/internal/trace/git/lines.go b/app/cli/internal/trace/git/lines.go new file mode 100644 index 000000000..dd40e652a --- /dev/null +++ b/app/cli/internal/trace/git/lines.go @@ -0,0 +1,236 @@ +// +// 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 ( + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "github.com/go-git/go-git/v6/plumbing" + fdiff "github.com/go-git/go-git/v6/plumbing/format/diff" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// CommitLines returns the lines that the commit added and removed, per file, +// against its first parent. +func (c *GoGitClient) CommitLines(repoRoot, sha string) ([]FileLines, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + commit, err := repo.CommitObject(plumbing.NewHash(sha)) + if err != nil { + return nil, fmt.Errorf("get commit %s: %w", sha, err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("get tree of %s: %w", sha, err) + } + + // A root commit has no parent, and its diff is against the empty tree. + var baseTree *object.Tree + parent, err := commit.Parents().Next() + switch { + case errors.Is(err, io.EOF): + case err != nil: + return nil, fmt.Errorf("get parent of %s: %w", sha, err) + default: + if baseTree, err = parent.Tree(); err != nil { + return nil, fmt.Errorf("get parent tree of %s: %w", sha, err) + } + } + + changes, err := object.DiffTree(baseTree, tree) + if err != nil { + return nil, fmt.Errorf("diff %s: %w", sha, err) + } + patch, err := changes.Patch() + if err != nil { + return nil, fmt.Errorf("patch %s: %w", sha, err) + } + + var out []FileLines + for _, fp := range patch.FilePatches() { + if fp.IsBinary() { + continue + } + + from, to := fp.Files() + fl := FileLines{} + if to != nil { + fl.Path = to.Path() + } else if from != nil { + fl.Path = from.Path() + } + + for _, chunk := range fp.Chunks() { + switch chunk.Type() { + case fdiff.Add: + fl.Added = append(fl.Added, splitLines(chunk.Content())...) + case fdiff.Delete: + fl.Removed = append(fl.Removed, splitLines(chunk.Content())...) + case fdiff.Equal: + } + } + out = append(out, fl) + } + + return out, nil +} + +// FileAt returns the content of path at the commit, or nil when the commit +// does not hold it. sha can be any revision, such as HEAD. +func (c *GoGitClient) FileAt(repoRoot, sha, path string) ([]byte, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + hash, err := repo.ResolveRevision(plumbing.Revision(sha)) + if err != nil { + return nil, fmt.Errorf("resolve %s: %w", sha, err) + } + commit, err := repo.CommitObject(*hash) + if err != nil { + return nil, fmt.Errorf("get commit %s: %w", sha, err) + } + + file, err := commit.File(path) + if errors.Is(err, object.ErrFileNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get %s at %s: %w", path, sha, err) + } + + content, err := file.Contents() + if err != nil { + return nil, fmt.Errorf("read %s at %s: %w", path, sha, err) + } + + return []byte(content), nil +} + +// CommitLines parses `git diff -U0` of the commit against its first parent. +func (c *ExecClient) CommitLines(repoRoot, sha string) ([]FileLines, error) { + base := commitParentOrEmptyTree(repoRoot, sha) + raw, err := gitRawOutput(repoRoot, "-c", "core.quotePath=false", "diff", "--no-color", "--no-ext-diff", + "--no-renames", "-U0", base, sha) + if err != nil { + return nil, fmt.Errorf("diff %s: %w", sha, err) + } + patch := string(raw) + + var out []FileLines + var cur *FileLines + var binary, inHunk bool + flush := func() { + if cur != nil && !binary { + out = append(out, *cur) + } + } + + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "diff --git ") { + flush() + cur, binary, inHunk = &FileLines{}, false, false + + continue + } + if cur == nil { + continue + } + + // Before the first hunk, the lines are headers. In a hunk, a removed + // line that starts with "-- " looks like a header, so the two are kept + // apart by position. Git ends a header path that holds a space with a + // tab. + if !inHunk { + switch { + case strings.HasPrefix(line, "--- a/"): + cur.Path = strings.TrimSuffix(strings.TrimPrefix(line, "--- a/"), "\t") + case strings.HasPrefix(line, "+++ b/"): + cur.Path = strings.TrimSuffix(strings.TrimPrefix(line, "+++ b/"), "\t") + case strings.HasPrefix(line, "Binary files "): + binary = true + case strings.HasPrefix(line, "@@"): + inHunk = true + } + + continue + } + + switch { + case strings.HasPrefix(line, "@@"): + case strings.HasPrefix(line, "+"): + cur.Added = append(cur.Added, line[1:]) + case strings.HasPrefix(line, "-"): + cur.Removed = append(cur.Removed, line[1:]) + } + } + flush() + + return out, nil +} + +// FileAt returns the content of path at the commit, or nil when the commit +// does not hold it. +func (c *ExecClient) FileAt(repoRoot, sha, path string) ([]byte, error) { + if _, err := gitRawOutput(repoRoot, "rev-parse", "--verify", "--quiet", sha+"^{commit}"); err != nil { + return nil, fmt.Errorf("resolve %s: %w", sha, err) + } + if _, err := gitRawOutput(repoRoot, "cat-file", "-e", sha+":"+path); err != nil { + return nil, nil //nolint:nilerr // the commit resolves, so a failure here means the path is absent + } + + out, err := gitRawOutput(repoRoot, "cat-file", "blob", sha+":"+path) + if err != nil { + return nil, fmt.Errorf("read %s at %s: %w", path, sha, err) + } + + return out, nil +} + +// gitRawOutput runs git and returns its output unchanged. gitOutput trims +// it, which would change file content. +func gitRawOutput(repoRoot string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + + return cmd.Output() +} + +// splitLines splits text into lines without their line ends. A final line +// with no newline is a line too, and empty text has no lines. +func splitLines(text string) []string { + if text == "" { + return nil + } + + lines := strings.SplitAfter(text, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + for i, l := range lines { + lines[i] = strings.TrimSuffix(l, "\n") + } + + return lines +} diff --git a/app/cli/internal/trace/git/lines_test.go b/app/cli/internal/trace/git/lines_test.go new file mode 100644 index 000000000..d72068de9 --- /dev/null +++ b/app/cli/internal/trace/git/lines_test.go @@ -0,0 +1,108 @@ +// +// 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 ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommitLinesAndFileAt(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + write := func(name, content string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0600)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + write("a.txt", "one\ntwo\nthree\n") + write("gone.txt", "bye\n") + write("bin.dat", "\x00\x01\x02") + run("add", ".") + run("commit", "-m", "root") + root := run("rev-parse", "HEAD") + + write("a.txt", "one\nTWO\nthree\nfour") + write("new.txt", "hello\n") + write("with space.txt", "spaced\n") + write("bin.dat", "\x00\x03") + require.NoError(t, os.Remove(filepath.Join(dir, "gone.txt"))) + run("add", "-A") + run("commit", "-m", "second") + second := run("rev-parse", "HEAD") + + clients := map[string]Client{"gogit": NewGoGitClient(), "exec": NewExecClient()} + for name, c := range clients { + t.Run(name, func(t *testing.T) { + t.Run("root commit adds every line", func(t *testing.T) { + got, err := c.CommitLines(dir, root) + require.NoError(t, err) + sortFileLines(got) + assert.Equal(t, []FileLines{ + {Path: "a.txt", Added: []string{"one", "two", "three"}}, + {Path: "gone.txt", Added: []string{"bye"}}, + }, got, "binary files are left out") + }) + + t.Run("commit lines against the first parent", func(t *testing.T) { + got, err := c.CommitLines(dir, second) + require.NoError(t, err) + sortFileLines(got) + assert.Equal(t, []FileLines{ + {Path: "a.txt", Added: []string{"TWO", "four"}, Removed: []string{"two"}}, + {Path: "gone.txt", Removed: []string{"bye"}}, + {Path: "new.txt", Added: []string{"hello"}}, + {Path: "with space.txt", Added: []string{"spaced"}}, + }, got) + }) + + t.Run("file content at a commit", func(t *testing.T) { + content, err := c.FileAt(dir, second, "a.txt") + require.NoError(t, err) + assert.Equal(t, "one\nTWO\nthree\nfour", string(content)) + + content, err = c.FileAt(dir, second, "gone.txt") + require.NoError(t, err) + assert.Nil(t, content, "a file the commit does not hold has no content") + + content, err = c.FileAt(dir, "HEAD", "new.txt") + require.NoError(t, err) + assert.Equal(t, "hello\n", string(content), "a revision name resolves") + + _, err = c.FileAt(dir, "deadbeef00000000000000000000000000000000", "a.txt") + assert.Error(t, err, "an unknown commit is an error, not an absent file") + }) + + t.Run("an unknown commit is an error", func(t *testing.T) { + _, err := c.CommitLines(dir, "deadbeef00000000000000000000000000000000") + assert.Error(t, err) + }) + }) + } +} + +func sortFileLines(fl []FileLines) { + sort.Slice(fl, func(i, j int) bool { return fl[i].Path < fl[j].Path }) +} diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go index b976b7a7a..b8d90938c 100644 --- a/app/cli/internal/trace/state/ailines.go +++ b/app/cli/internal/trace/state/ailines.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -37,6 +38,24 @@ type AILineAttribution struct { // history, but matching against it credits a finished session on every // later commit that happens to touch a file it once edited. Pending map[string]PendingEdit `json:"-"` + // Written holds, for each file, the fingerprint of every line that the + // session's edits wrote. Push matches the lines of a commit against it. + Written map[string]LineCounts `json:"-"` + // Erased holds, for each file, the fingerprint of every line that the + // session's edits removed. Only these removals count as AI removals + // (R-005). + Erased map[string]LineCounts `json:"-"` + // Open holds, for each file, the lines that the session added or removed + // and that no commit took yet (R-006). + Open map[string]*OpenLines `json:"-"` + // Legacy marks each file with an edit recorded before line fingerprints + // existed. Its attribution falls back to the recorded ranges. + Legacy map[string]bool `json:"-"` + // GeneratedAdded and GeneratedRemoved are the lines that all the agent + // edits of the session added and removed, also the ones that never + // reached a commit (R-011). + GeneratedAdded int `json:"-"` + GeneratedRemoved int `json:"-"` } // PendingEdit is an uncommitted edit to one file. @@ -59,9 +78,75 @@ func newAILineAttribution(sessionID string) *AILineAttribution { SessionID: sessionID, Files: make(map[string][]aicodingsession.LineRange), Pending: make(map[string]PendingEdit), + Written: make(map[string]LineCounts), + Erased: make(map[string]LineCounts), + Open: make(map[string]*OpenLines), + Legacy: make(map[string]bool), } } +// open returns the open lines of a file, creating them on first use. +func (a *AILineAttribution) open(file string) *OpenLines { + o, ok := a.Open[file] + if !ok { + o = &OpenLines{Added: LineCounts{}, Removed: LineCounts{}} + a.Open[file] = o + } + + return o +} + +// countsOf returns the LineCounts of a file in m, creating them on first use. +func countsOf(m map[string]LineCounts, file string) LineCounts { + c, ok := m[file] + if !ok { + c = LineCounts{} + m[file] = c + } + + return c +} + +// settle closes the open lines of a file that are in set. +func (a *AILineAttribution) settle(file string, set *LineSet) { + o, ok := a.Open[file] + if !ok || set == nil { + return + } + for _, fp := range set.Added { + o.Added.Take(fp) + } + for _, fp := range set.Removed { + o.Removed.Take(fp) + } +} + +// applyEdit adds the lines of a fingerprinted edit to the session's history +// and to its open lines. A removal of an open line of the session closes that +// line: it never reached a commit, so no commit can remove it. In the same +// way, writing back an open removal closes it: the line is as it was +// committed, so no commit can add it. +func (a *AILineAttribution) applyEdit(entry *aiLineEntry) { + lines := entry.Lines + countsOf(a.Written, entry.File).Add(lines.Added...) + countsOf(a.Erased, entry.File).Add(lines.Removed...) + + o := a.open(entry.File) + for _, fp := range lines.Removed { + if !o.Added.Take(fp) { + o.Removed.Add(fp) + } + } + for _, fp := range lines.Added { + if !o.Removed.Take(fp) { + o.Added.Add(fp) + } + } + + a.GeneratedAdded += entry.LinesAdded + a.GeneratedRemoved += entry.LinesRemoved +} + // aiLinesPath returns the path for a session's AI line attribution file (JSONL). func (s *Store) aiLinesPath(sessionID string) string { return filepath.Join(s.traceDirPath(), traceDirAILines, sanitizeID(sessionID)+".jsonl") @@ -81,15 +166,28 @@ type aiLineEntry struct { // would otherwise keep crediting their session forever. RecordedAt string `json:"recorded_at,omitempty"` // ConsumedBy is the SHA of the commit that committed the file's pending - // ranges. Set only on marker entries appended by MarkConsumed, which + // ranges. Set only on marker entries appended by MarkCommitted, which // clear the file from Pending instead of contributing ranges. ConsumedBy string `json:"consumed_by,omitempty"` // ConsumedSeq is the ledger position of the edit a marker retires. It - // bounds the marker to the snapshot MarkConsumed read, so an edit appended + // bounds the marker to the snapshot MarkCommitted read, so an edit appended // afterwards — which the commit cannot have contained — keeps its pending // state. Zero on a marker written before this field existed, which retires // the file outright. ConsumedSeq int `json:"consumed_seq,omitempty"` + // Lines holds the fingerprints of the lines that an edit added and + // removed. Nil on an edit recorded before line fingerprints existed, and + // on markers. + Lines *LineSet `json:"lines,omitempty"` + // LinesAdded and LinesRemoved are the lines that an edit added and + // removed, as the line diff counts them (R-011). + LinesAdded int `json:"lines_added,omitempty"` + LinesRemoved int `json:"lines_removed,omitempty"` + // Settled holds the fingerprints of open lines that are closed: on a + // marker, the lines that the commit took or that are gone from the file; + // on an entry without ConsumedBy, the lines that changed outside the + // session's edits (R-002). + Settled *LineSet `json:"settled,omitempty"` } // LoadAILineAttribution loads the AI line attribution for a session by @@ -107,6 +205,12 @@ func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { // Entries are appended in order, so replaying them in order lets a file // go pending -> consumed -> pending again across successive edits and // commits. + // + // A legacy edit (no fingerprints) is pending until a marker retires the + // whole file. A fingerprinted file is pending while it has open lines, + // with the time of its last edit. + legacyPending := make(map[string]PendingEdit) + lastEdit := make(map[string]PendingEdit) seq := 0 dec := json.NewDecoder(f) for dec.More() { @@ -121,24 +225,50 @@ func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { if entry.ConsumedBy != "" { // Retire only the edit the commit actually contained. A concurrent - // RecordLineRanges can land between MarkConsumed reading the + // RecordLineRanges can land between MarkCommitted reading the // pending set and appending its markers, leaving a marker behind an // edit it never saw; that edit sits past the marker's snapshot, so // it keeps its pending state. - cur, pending := attr.Pending[entry.File] + cur, pending := legacyPending[entry.File] if pending && (entry.ConsumedSeq == 0 || cur.Seq <= entry.ConsumedSeq) { - delete(attr.Pending, entry.File) + delete(legacyPending, entry.File) } + attr.settle(entry.File, entry.Settled) + + continue + } + + if entry.Settled != nil { + attr.settle(entry.File, entry.Settled) continue } attr.Files[entry.File] = append(attr.Files[entry.File], entry.Ranges...) + if entry.Lines != nil { + attr.applyEdit(&entry) + } else { + attr.Legacy[entry.File] = true + } + // An entry with no parseable timestamp is never pending: it either // predates consumption tracking, in which case its session is long // gone, or it cannot be ordered against a competing session's edit. - if recordedAt, err := time.Parse(time.RFC3339, entry.RecordedAt); err == nil { - attr.Pending[entry.File] = PendingEdit{At: recordedAt, Seq: seq} + recordedAt, err := time.Parse(time.RFC3339, entry.RecordedAt) + if err != nil { + continue + } + edit := PendingEdit{At: recordedAt, Seq: seq} + lastEdit[entry.File] = edit + if entry.Lines == nil { + legacyPending[entry.File] = edit + } + } + + for file, edit := range lastEdit { + _, legacy := legacyPending[file] + if legacy || !attr.Open[file].isEmpty() { + attr.Pending[file] = edit } } @@ -164,40 +294,89 @@ func (s *Store) RecordLineRangesAt(sessionID, filePath string, ranges []aicoding }}) } -// MarkConsumed records that the pending ranges of the given files landed in -// commit sha, so they stop crediting the session on later commits. Files the -// session has nothing pending for are ignored, which keeps the ledger from -// growing a marker per commit per file the session never touched. -func (s *Store) MarkConsumed(sessionID string, files []string, sha string) error { - if sha == "" || len(files) == 0 { +// RecordEdit appends one fingerprinted agent edit of a file to a session's +// ledger. +func (s *Store) RecordEdit(sessionID, filePath string, edit EditRecord) error { + return s.RecordEditAt(sessionID, filePath, edit, time.Now().UTC()) +} + +// RecordEditAt records an edit made at a given time. See RecordLineRangesAt +// for why tests set the time. +func (s *Store) RecordEditAt(sessionID, filePath string, edit EditRecord, at time.Time) error { + lines := edit.Lines + + return s.appendAILineEntries(sessionID, []aiLineEntry{{ + SessionID: sessionID, + File: filePath, + Ranges: edit.Ranges, + RecordedAt: at.UTC().Format(time.RFC3339Nano), + Lines: &lines, + LinesAdded: edit.AddedCount, + LinesRemoved: edit.RemovedCount, + }}) +} + +// SettleLines closes open lines of a file that changed outside the session's +// own edits (R-002): a human, or another session, rewrote or removed them. An +// empty set records nothing. +func (s *Store) SettleLines(sessionID, filePath string, lines LineSet) error { + if lines.IsEmpty() { + return nil + } + + return s.appendAILineEntries(sessionID, []aiLineEntry{{ + SessionID: sessionID, + File: filePath, + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Settled: &lines, + }}) +} + +// MarkCommitted records that commit sha landed pending work of a session, so +// that work stops crediting the session on later commits. settled holds, for +// each file of the commit, the open lines that the commit took or that are +// gone from the file; the other open lines stay pending (R-006). A legacy file +// of the commit is retired as a whole. Files the session has nothing pending +// for are ignored, which keeps the ledger from growing a marker per commit per +// file the session never touched. +func (s *Store) MarkCommitted(sessionID, sha string, settled map[string]LineSet) error { + if sha == "" || len(settled) == 0 { return nil } - return s.markConsumedAt(s.LoadAILineAttribution(sessionID).Pending, sessionID, files, sha) + return s.markCommittedAt(s.LoadAILineAttribution(sessionID).Pending, sessionID, sha, settled) } -// markConsumedAt writes the retirement markers for a pending snapshot. The -// snapshot is a parameter because it is the boundary this has to be correct -// across: an edit recorded after it was taken is not part of the commit, and -// pinning each marker to the position of the edit it retires is what keeps that -// edit pending. -func (s *Store) markConsumedAt(pending map[string]PendingEdit, sessionID string, files []string, sha string) error { +// markCommittedAt writes the markers for a pending snapshot. The snapshot is a +// parameter because it is the boundary this has to be correct across: an edit +// recorded after it was taken is not part of the commit, and pinning each +// marker to the position of the edit it retires is what keeps that edit +// pending. +func (s *Store) markCommittedAt(pending map[string]PendingEdit, sessionID, sha string, settled map[string]LineSet) error { if len(pending) == 0 { return nil } + files := make([]string, 0, len(settled)) + for file := range settled { + files = append(files, file) + } + sort.Strings(files) + entries := make([]aiLineEntry, 0, len(files)) for _, file := range files { edit, ok := pending[file] if !ok { continue } + set := settled[file] entries = append(entries, aiLineEntry{ SessionID: sessionID, File: file, RecordedAt: edit.At.Format(time.RFC3339Nano), ConsumedBy: sha, ConsumedSeq: edit.Seq, + Settled: &set, }) } diff --git a/app/cli/internal/trace/state/ailines_test.go b/app/cli/internal/trace/state/ailines_test.go new file mode 100644 index 000000000..3540057c9 --- /dev/null +++ b/app/cli/internal/trace/state/ailines_test.go @@ -0,0 +1,169 @@ +// +// 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 state + +import ( + "testing" + "time" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLineCounts(t *testing.T) { + c := LineCounts{} + c.Add("x", "x", "y") + assert.Equal(t, 3, c.Len()) + + clone := c.Clone() + assert.True(t, c.Take("x")) + assert.True(t, c.Take("x")) + assert.False(t, c.Take("x"), "only two copies were added") + assert.Equal(t, 1, c.Len()) + assert.Equal(t, 3, clone.Len(), "a clone does not share counts") + + present := LineCounts{"y": 1, "z": 1} + assert.Empty(t, c.Missing(present)) + assert.Equal(t, 2, present.Len(), "Missing does not change its argument") + assert.Equal(t, []string{"x", "x"}, LineCounts{"x": 2, "y": 1}.Missing(LineCounts{"y": 3})) +} + +func TestAILineAttribution_Lines(t *testing.T) { + const oldLine = "old" + + edit := func(added, removed []string) EditRecord { + return EditRecord{ + Ranges: []aicodingsession.LineRange{{Start: 1, End: len(added)}}, + Lines: LineSet{Added: added, Removed: removed}, + AddedCount: len(added), + RemovedCount: len(removed), + } + } + + t.Run("an edit opens the lines it wrote and removed", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a", "b"}, []string{oldLine}))) + + attr := store.LoadAILineAttribution("sess") + assert.Equal(t, LineCounts{"a": 1, "b": 1}, attr.Written[fileA]) + assert.Equal(t, LineCounts{oldLine: 1}, attr.Erased[fileA]) + assert.Equal(t, LineCounts{"a": 1, "b": 1}, attr.Open[fileA].Added) + assert.Equal(t, LineCounts{oldLine: 1}, attr.Open[fileA].Removed) + assert.Contains(t, attr.Pending, fileA) + assert.False(t, attr.Legacy[fileA]) + }) + + t.Run("removing an open line of the session closes it", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a"}, nil))) + require.NoError(t, store.RecordEdit("sess", fileA, edit(nil, []string{"a"}))) + + attr := store.LoadAILineAttribution("sess") + assert.Zero(t, attr.Open[fileA].Added.Len()) + assert.Zero(t, attr.Open[fileA].Removed.Len(), "the line never reached a commit, so no commit can remove it") + assert.NotContains(t, attr.Pending, fileA) + assert.Equal(t, LineCounts{"a": 1}, attr.Erased[fileA], "the erased history keeps every removal") + }) + + t.Run("writing back a removed line closes the removal", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit(nil, []string{oldLine}))) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{oldLine}, nil))) + + attr := store.LoadAILineAttribution("sess") + assert.Zero(t, attr.Open[fileA].Added.Len(), "the line is back as it was committed, so no commit can add it") + assert.Zero(t, attr.Open[fileA].Removed.Len()) + assert.NotContains(t, attr.Pending, fileA, "an undo leaves nothing to credit") + assert.Equal(t, LineCounts{oldLine: 1}, attr.Written[fileA], "the written history keeps every line") + }) + + t.Run("lines changed outside the session close", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a", "b"}, nil))) + require.NoError(t, store.SettleLines("sess", fileA, LineSet{Added: []string{"b"}})) + + attr := store.LoadAILineAttribution("sess") + assert.Equal(t, LineCounts{"a": 1}, attr.Open[fileA].Added) + assert.Equal(t, LineCounts{"a": 1, "b": 1}, attr.Written[fileA], "written lines are history") + assert.Contains(t, attr.Pending, fileA) + + require.NoError(t, store.SettleLines("sess", fileA, LineSet{Added: []string{"a"}})) + assert.NotContains(t, store.LoadAILineAttribution("sess").Pending, fileA, + "a file with no open lines does not credit the session on a later commit") + }) + + t.Run("a commit closes only the lines it took (R-006)", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a", "b"}, nil))) + require.NoError(t, store.MarkCommitted("sess", "sha1", map[string]LineSet{fileA: {Added: []string{"a"}}})) + + attr := store.LoadAILineAttribution("sess") + assert.Equal(t, LineCounts{"b": 1}, attr.Open[fileA].Added) + assert.Contains(t, attr.Pending, fileA, "the rest of the file stays pending for the session") + + require.NoError(t, store.MarkCommitted("sess", "sha2", map[string]LineSet{fileA: {Added: []string{"b"}}})) + assert.NotContains(t, store.LoadAILineAttribution("sess").Pending, fileA) + }) + + t.Run("an edit recorded after the commit snapshot stays open", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a"}, nil))) + pending := store.LoadAILineAttribution("sess").Pending + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"c"}, nil))) + require.NoError(t, store.markCommittedAt(pending, "sess", "sha1", map[string]LineSet{fileA: {Added: []string{"a"}}})) + + attr := store.LoadAILineAttribution("sess") + assert.Equal(t, LineCounts{"c": 1}, attr.Open[fileA].Added) + assert.Contains(t, attr.Pending, fileA) + }) + + t.Run("generated line counts include edits that never reach a commit (R-011)", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordEdit("sess", fileA, edit([]string{"a", "b"}, []string{"x"}))) + require.NoError(t, store.RecordEdit("sess", "b.go", EditRecord{AddedCount: 4, RemovedCount: 2, Lines: LineSet{}})) + require.NoError(t, store.SettleLines("sess", fileA, LineSet{Added: []string{"a", "b"}})) + + attr := store.LoadAILineAttribution("sess") + assert.Equal(t, 6, attr.GeneratedAdded) + assert.Equal(t, 3, attr.GeneratedRemoved) + }) + + t.Run("an entry without fingerprints is legacy", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.RecordLineRanges("sess", fileA, []aicodingsession.LineRange{{Start: 1, End: 2}})) + require.NoError(t, store.RecordEdit("sess", "b.go", edit([]string{"a"}, nil))) + + attr := store.LoadAILineAttribution("sess") + assert.True(t, attr.Legacy[fileA]) + assert.False(t, attr.Legacy["b.go"]) + assert.Contains(t, attr.Pending, fileA) + assert.Contains(t, attr.Pending, "b.go") + + // A commit of the legacy file retires it as a whole, as before. + require.NoError(t, store.MarkCommitted("sess", "sha1", map[string]LineSet{fileA: {}})) + assert.NotContains(t, store.LoadAILineAttribution("sess").Pending, fileA) + }) + + t.Run("the pending edit time is the last edit of the file", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + first := time.Now().UTC().Add(-time.Hour) + require.NoError(t, store.RecordEditAt("sess", fileA, edit([]string{"a"}, nil), first)) + require.NoError(t, store.RecordEditAt("sess", fileA, edit([]string{"b"}, nil), first.Add(time.Minute))) + + assert.True(t, store.LoadAILineAttribution("sess").Pending[fileA].At.Equal(first.Add(time.Minute))) + }) +} diff --git a/app/cli/internal/trace/state/lines.go b/app/cli/internal/trace/state/lines.go new file mode 100644 index 000000000..7013cb45a --- /dev/null +++ b/app/cli/internal/trace/state/lines.go @@ -0,0 +1,117 @@ +// +// 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 state + +import ( + "sort" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// LineCounts is a multiset of line fingerprints: how many lines with each +// content key. Line attribution matches lines by content, and a file can hold +// the same line more than once, so each match takes one copy. +type LineCounts map[string]int + +// Add adds one copy of each fingerprint. +func (c LineCounts) Add(fps ...string) { + for _, fp := range fps { + c[fp]++ + } +} + +// Take removes one copy of fp, and reports whether there was one. +func (c LineCounts) Take(fp string) bool { + if c[fp] == 0 { + return false + } + c[fp]-- + if c[fp] == 0 { + delete(c, fp) + } + + return true +} + +// Len returns the number of lines, counting each copy. +func (c LineCounts) Len() int { + n := 0 + for _, k := range c { + n += k + } + + return n +} + +// Clone returns a copy that does not share counts with c. +func (c LineCounts) Clone() LineCounts { + out := make(LineCounts, len(c)) + for fp, k := range c { + out[fp] = k + } + + return out +} + +// Missing returns the lines of c that present does not hold, one entry per +// copy, sorted. present is not changed. +func (c LineCounts) Missing(present LineCounts) []string { + var out []string + for fp, k := range c { + for range k - present[fp] { + out = append(out, fp) + } + } + sort.Strings(out) + + return out +} + +// LineSet lists the fingerprints of lines that a change added and removed. +type LineSet struct { + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` +} + +// IsEmpty reports whether the set holds no line. +func (s LineSet) IsEmpty() bool { + return len(s.Added) == 0 && len(s.Removed) == 0 +} + +// OpenLines are the lines of one file that a session's edits added or +// removed and that no commit has taken yet. While a file has open lines, a +// commit of the file can be credited to the session. +type OpenLines struct { + Added LineCounts + Removed LineCounts +} + +// isEmpty reports whether no line is open. +func (o *OpenLines) isEmpty() bool { + return o == nil || (len(o.Added) == 0 && len(o.Removed) == 0) +} + +// EditRecord is one agent edit of a file, as the ledger records it. +type EditRecord struct { + // Ranges are the line ranges that the edit wrote in the new content. + Ranges []aicodingsession.LineRange + // Lines are the fingerprints of the lines that the edit added and + // removed, without the lines that it only re-indented or moved. + Lines LineSet + // AddedCount and RemovedCount are the lines that the edit added and + // removed, as the line diff counts them (R-011). + AddedCount, RemovedCount int +} diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 2be3411d6..5160340ff 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -22,6 +22,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // snapshotPath returns the path for a file snapshot: /chainloop-trace/snapshots// @@ -123,7 +124,50 @@ func (s *Store) LoadShellPreSignature(key ShellCallKey) (WorktreeSignatures, err return sig, nil } -// DeleteShellPreSignature removes the pre-command signature once processed. +// DeleteShellPreSignature removes the pre-command signature, and the HEAD +// commits saved with it, once processed. func (s *Store) DeleteShellPreSignature(key ShellCallKey) { _ = os.Remove(s.shellPreSignaturePath(key)) + _ = os.Remove(s.shellPreHeadsPath(key)) +} + +// shellPreHeadsPath returns the path storing the HEAD commit of each checkout +// before a shell command, next to the command's signature. +func (s *Store) shellPreHeadsPath(key ShellCallKey) string { + return strings.TrimSuffix(s.shellPreSignaturePath(key), ".json") + ".heads.json" +} + +// SaveShellPreHeads stores the HEAD commit of each checkout (checkout root → +// SHA) before an agent-run shell command. The signature holds hashes only, so +// the file at that commit stands in for the content before the command, also +// when the command commits its own change. +func (s *Store) SaveShellPreHeads(key ShellCallKey, heads map[string]string) error { + path := s.shellPreHeadsPath(key) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + data, err := json.Marshal(heads) + if err != nil { + return fmt.Errorf("marshal shell heads: %w", err) + } + + return os.WriteFile(path, data, 0600) +} + +// LoadShellPreHeads loads the HEAD commits saved before a shell command. A +// command whose pre hook ran on an older CLI has none, and the error then +// wraps os.ErrNotExist. +func (s *Store) LoadShellPreHeads(key ShellCallKey) (map[string]string, error) { + data, err := os.ReadFile(s.shellPreHeadsPath(key)) + if err != nil { + return nil, err + } + + var heads map[string]string + if err := json.Unmarshal(data, &heads); err != nil { + return nil, fmt.Errorf("parse shell heads: %w", err) + } + + return heads, nil } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index ef262d656..69945fff4 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -99,6 +99,31 @@ func TestShellPreSignatureSlots(t *testing.T) { } } +func TestShellPreHeads(t *testing.T) { + store := NewGitStore(t.TempDir()) + key := ShellCallKey{SessionID: shellSessionID, ToolUseID: "toolu_1"} + + _, err := store.LoadShellPreHeads(key) + require.ErrorIs(t, err, os.ErrNotExist, "a command without saved heads has none") + + heads := map[string]string{"/repo": "0123456789abcdef"} + require.NoError(t, store.SaveShellPreSignature(key, WorktreeSignatures{"/repo": {fileA: "h"}})) + require.NoError(t, store.SaveShellPreHeads(key, heads)) + got, err := store.LoadShellPreHeads(key) + require.NoError(t, err) + assert.Equal(t, heads, got) + + store.DeleteShellPreSignature(key) + _, err = store.LoadShellPreHeads(key) + require.ErrorIs(t, err, os.ErrNotExist, "the heads go with the signature") + + // A corrupt file is an error, not an absent one. + require.NoError(t, os.WriteFile(store.shellPreHeadsPath(key), []byte("{"), 0600)) + _, err = store.LoadShellPreHeads(key) + require.Error(t, err) + assert.NotErrorIs(t, err, os.ErrNotExist) +} + // A CLI upgrade can land between the pre and post hooks of one command. The // earlier single-checkout format must then be rejected, not misread. func TestShellPreSignatureRejectsSingleCheckoutFormat(t *testing.T) { diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index cc9a5570f..8ac3f7605 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -485,7 +485,7 @@ func TestAILinePendingTracking(t *testing.T) { require.NoError(t, store.InitTraceDir()) require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) - require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.MarkCommitted("sess-1", "sha-1", map[string]LineSet{fileA: {}})) attr := store.LoadAILineAttribution("sess-1") assert.Empty(t, attr.Pending, "a committed file must stop crediting the session") @@ -500,7 +500,7 @@ func TestAILinePendingTracking(t *testing.T) { require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) require.NoError(t, store.RecordLineRanges("sess-1", "b.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) - require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.MarkCommitted("sess-1", "sha-1", map[string]LineSet{fileA: {}})) pending := store.LoadAILineAttribution("sess-1").Pending assert.NotContains(t, pending, fileA) @@ -512,7 +512,7 @@ func TestAILinePendingTracking(t *testing.T) { require.NoError(t, store.InitTraceDir()) require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) - require.NoError(t, store.MarkConsumed("sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.MarkCommitted("sess-1", "sha-1", map[string]LineSet{fileA: {}})) require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}})) assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA) @@ -523,7 +523,7 @@ func TestAILinePendingTracking(t *testing.T) { require.NoError(t, store.InitTraceDir()) require.NoError(t, store.RecordLineRanges("sess-1", fileA, []aicodingsession.LineRange{{Start: 1, End: 5}})) - require.NoError(t, store.MarkConsumed("sess-1", []string{"untouched.go"}, "sha-1")) + require.NoError(t, store.MarkCommitted("sess-1", "sha-1", map[string]LineSet{"untouched.go": {}})) attr := store.LoadAILineAttribution("sess-1") assert.Contains(t, attr.Pending, fileA) @@ -570,7 +570,7 @@ func TestAILinePendingTracking(t *testing.T) { }) t.Run("a marker does not retire an edit recorded after it was built", func(t *testing.T) { - // MarkConsumed reads the pending set and then appends; an agent editing + // MarkCommitted reads the pending set and then appends; an agent editing // the same file in between leaves a marker sitting behind an edit the // commit never contained. That edit must stay pending. store := NewGitStore(t.TempDir()) @@ -584,7 +584,7 @@ func TestAILinePendingTracking(t *testing.T) { // The racing edit lands first, then the marker for the older one. require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}}, committed.Add(time.Second))) - require.NoError(t, store.markConsumedAt(pendingBefore, "sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.markCommittedAt(pendingBefore, "sess-1", "sha-1", map[string]LineSet{fileA: {}})) assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA, "the edit made after the snapshot was not in the commit") @@ -604,7 +604,7 @@ func TestAILinePendingTracking(t *testing.T) { require.Contains(t, pendingBefore, fileA) require.NoError(t, store.RecordLineRangesAt("sess-1", fileA, []aicodingsession.LineRange{{Start: 8, End: 9}}, tick)) - require.NoError(t, store.markConsumedAt(pendingBefore, "sess-1", []string{fileA}, "sha-1")) + require.NoError(t, store.markCommittedAt(pendingBefore, "sess-1", "sha-1", map[string]LineSet{fileA: {}})) assert.Contains(t, store.LoadAILineAttribution("sess-1").Pending, fileA, "an identical timestamp must not make the later edit indistinguishable") diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index c52faa89f..50bca42b6 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -430,6 +430,7 @@ func shellCallKey(input *trace.HookInput) state.ShellCallKey { func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.ShellCallKey, log zerolog.Logger) { client := tracegit.NewGoGitClient() sigs := make(state.WorktreeSignatures) + heads := make(map[string]string) for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { sig, err := client.SnapshotWorktree(root) if err != nil { @@ -437,6 +438,11 @@ func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.Shel continue } sigs[root] = sig + + // A checkout with no commit yet has no HEAD; its files count as new. + if sha, _, err := client.CommitHeadInfo(root); err == nil { + heads[root] = sha + } } if len(sigs) == 0 { @@ -446,6 +452,9 @@ func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.Shel if err := store.SaveShellPreSignature(key, sigs); err != nil { log.Debug().Err(err).Msg("pre-command: save worktree signature failed") } + if err := store.SaveShellPreHeads(key, heads); err != nil { + log.Debug().Err(err).Msg("pre-command: save HEAD commits failed") + } } // shellSnapshotRoots returns the checkouts that a shell command of the session @@ -796,8 +805,6 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { before := provider.ResolveBeforeContent(store, input, after) - ranges, changed := attribution.ComputeLineRanges(before, after) - // Normalize to repo-relative path for storage — git diffs use relative paths, // so ai-lines keys must match for commit correlation and attribution to work. relPath, err := filepath.Rel(repoRoot, input.FilePath) @@ -805,19 +812,35 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { relPath = input.FilePath } - if changed { - if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { - log.Debug().Err(err).Msg("post-tool-use: record line ranges failed") - } else { - log.Debug().Int("ranges", len(ranges)).Str("file", relPath).Msg("line ranges recorded") - } - } + recordFileEdit(store, sessionID, relPath, before, after, log) provider.CleanupAfterEdit(store, input) return nil } +// recordFileEdit records one agent edit of a file in the session's ledger. +func recordFileEdit(store *state.Store, sessionID, relPath string, before, after []byte, log zerolog.Logger) { + // Lines of the session that changed since its last edit of the file were + // changed by someone else (R-002). They close before this edit records its + // own lines, so that a line this edit writes again is the edit's. + replaced := attribution.ReplacedLines(store.LoadAILineAttribution(sessionID).Open[relPath], before) + if err := store.SettleLines(sessionID, relPath, replaced); err != nil { + log.Debug().Err(err).Msg("post-tool-use: settle replaced lines failed") + } + + edit := attribution.ComputeEdit(before, after) + if !edit.Changed { + return + } + + if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { + log.Debug().Err(err).Msg("post-tool-use: record line ranges failed") + } else { + log.Debug().Int("ranges", len(edit.Ranges)).Str("file", relPath).Msg("line ranges recorded") + } +} + // recordCommandLineRanges diffs each worktree signature captured before a // shell command against its current worktree, and records the changes in the // ledger of the checkout that holds them. store and repoRoot are the @@ -833,8 +856,19 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st return } defer store.DeleteShellPreSignature(key) + // A pre hook from an older CLI saved no HEAD. That case, and a heads file + // that cannot be read, fall back to the current HEAD. + heads, err := store.LoadShellPreHeads(key) + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debug().Err(err).Msg("post-command: cannot read pre-command HEAD commits; using the current HEAD") + } for root, rootBefore := range before { + base := heads[root] + if base == "" { + base = "HEAD" + } + rootStore := store if !sameDir(root, repoRoot) { s, r, err := state.LocateFrom(root) @@ -846,22 +880,43 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st rootStore, root = s, r } - recordRootChanges(rootStore, root, rootBefore, key.SessionID, log) + recordRootChanges(rootStore, root, base, rootBefore, key.SessionID, log) } } // recordRootChanges diffs the signature of one checkout captured before a // shell command against its current worktree, and records every -// created/modified file (whole-file range) and every deleted file as -// AI-attributed. Enrich later caps the AI line count to each file's committed -// diff totals, so whole-file ranges yield correct counts. -func recordRootChanges(store *state.Store, repoRoot string, before map[string]string, sessionID string, log zerolog.Logger) { - after, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) +// created/modified file and every deleted file as AI-attributed. +// +// The signature holds hashes, not content, so the content before the command +// is not known. The file at base, the HEAD commit before the command, stands in +// for it: lines the command did not touch are then not claimed, also when the +// command commits its own change. Uncommitted lines are in the diff too. Those +// that an earlier edit of the session recorded are left out (DropOpen); other +// ones, such as uncommitted human lines, are claimed. An untracked file counts +// as new. +func recordRootChanges(store *state.Store, repoRoot, base string, before map[string]string, sessionID string, log zerolog.Logger) { + client := tracegit.NewGoGitClient() + after, err := client.SnapshotWorktree(repoRoot) if err != nil { log.Debug().Err(err).Str("root", repoRoot).Msg("post-command: worktree snapshot failed") return } + atBase := func(relPath string) []byte { + content, err := client.FileAt(repoRoot, base, filepath.ToSlash(relPath)) + if err != nil { + return nil + } + + return content + } + + open := store.LoadAILineAttribution(sessionID).Open + record := func(relPath string, edit attribution.Edit) error { + return store.RecordEdit(sessionID, relPath, editRecord(attribution.DropOpen(edit, open[relPath]))) + } + changed, deleted := attribution.ChangedPaths(before, after) for _, relPath := range changed { content, err := os.ReadFile(filepath.Join(repoRoot, relPath)) @@ -869,18 +924,16 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st continue } // ChangedPaths already proved the file changed, so record it even when - // the result is empty (created empty or truncated) — ComputeLineRanges - // returns nil ranges for empty content, which marks the file AI-touched - // with zero added lines, matching the deletion-only treatment. - ranges, _ := attribution.ComputeLineRanges(nil, content) - if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { + // the diff finds nothing (created empty, or restored to base): the + // file is then AI-touched with zero lines, as a deletion-only edit. + if err := record(relPath, attribution.ComputeEdit(atBase(relPath), content)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record line ranges failed") } } for _, relPath := range deleted { - // nil ranges records the file as AI-touched (deletion-only), matching - // the file-writing hook's treatment of deletions. - if err := store.RecordLineRanges(sessionID, relPath, nil); err != nil { + // The removed lines are the file at base, matching the file-writing + // hook's treatment of deletions. + if err := record(relPath, attribution.ComputeEdit(atBase(relPath), nil)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record deletion failed") } } @@ -888,6 +941,16 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st log.Debug().Int("changed", len(changed)).Int("deleted", len(deleted)).Str("session_id", sessionID).Str("root", repoRoot).Msg("command line ranges recorded") } +// editRecord converts a computed edit into its ledger record. +func editRecord(edit attribution.Edit) state.EditRecord { + return state.EditRecord{ + Ranges: edit.Ranges, + Lines: state.LineSet{Added: edit.Added, Removed: edit.Removed}, + AddedCount: edit.AddedCount, + RemovedCount: edit.RemovedCount, + } +} + // autoInstallGitHooks installs git hooks if they're not already present // and a project name is discoverable. Under `chainloop trace run`, // pre-push is omitted because trace run drives attestation itself; a diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index fe34ed875..bc16bbc22 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -17,12 +17,14 @@ package action import ( "bufio" + "bytes" "context" "encoding/json" "fmt" "io" "maps" "os" + "path/filepath" "sort" "strconv" "strings" @@ -286,17 +288,14 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { return fmt.Errorf("save commit record: %w", err) } - // The credited sessions' pending ranges have now landed, so retire them. + // The credited sessions' pending lines have now landed, so retire them. // Skipping this is what makes a session accumulate foreign commits: its // files stay pending and keep matching every later commit that touches // them. Post-commit errors only ever get logged — the commit already // exists — so returning here costs nothing but surfaces the failure. if len(sessionIDs) > 0 { - committed := commitFiles(client, repoRoot, sha) - for _, sid := range sessionIDs { - if err := store.MarkConsumed(sid, committed, sha); err != nil { - return fmt.Errorf("mark attribution consumed for session %s: %w", sid, err) - } + if err := settleCommittedLines(client, store, repoRoot, sha, sessionIDs); err != nil { + return err } } @@ -305,6 +304,46 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { return nil } +// settleCommittedLines closes, for each session of a commit, the open lines +// that the commit took and the ones that are gone from the worktree. The +// session's other open lines stay pending for a later commit (R-006). +func settleCommittedLines(client tracegit.Client, store *state.Store, repoRoot, sha string, sessionIDs []string) error { + committed := commitFiles(client, repoRoot, sha) + if len(committed) == 0 { + return nil + } + + // A commit that cannot be read settles no line. Its files still get a + // marker, so that legacy files retire as before. + linesByPath := make(map[string]tracegit.FileLines) + if lines, err := client.CommitLines(repoRoot, sha); err == nil { + for _, fl := range lines { + linesByPath[fl.Path] = fl + } + } + + for _, sid := range sessionIDs { + attr := store.LoadAILineAttribution(sid) + settled := make(map[string]state.LineSet, len(committed)) + for _, path := range committed { + if _, pending := attr.Pending[path]; !pending { + continue + } + + worktree, _ := os.ReadFile(filepath.Join(repoRoot, path)) + atCommit, err := client.FileAt(repoRoot, sha, path) + clean := err == nil && bytes.Equal(worktree, atCommit) + settled[path] = attribution.SettleCommit(attr.Open[path], linesByPath[path], worktree, clean) + } + + if err := store.MarkCommitted(sid, sha, settled); err != nil { + return fmt.Errorf("mark attribution committed for session %s: %w", sid, err) + } + } + + return nil +} + // HandlePostRewriteHook handles the post-rewrite git hook, which git runs after // `commit --amend` and `rebase` with one " [extra]" line per // rewritten commit on stdin. @@ -859,6 +898,18 @@ func buildSessionEvidence(ctx context.Context, store *state.Store, repoRoot stri isGenerated := gitClient.GeneratedMatcher(repoRoot) + // Every ledger, because a commit can hold the lines of more than one + // session, and the lines of each commit, read once for all sessions. + ledgers := make(map[string]*state.AILineAttribution) + if attrs, err := store.LoadAllAILineAttributions(); err != nil { + log.Debug().Err(err).Msg("could not load AI line attributions") + } else { + for _, a := range attrs { + ledgers[a.SessionID] = a + } + } + commitLines := make(map[string][]tracegit.FileLines) + var sessions []sessionEvidence for sessionID, commits := range sessionCommits { @@ -940,9 +991,8 @@ func buildSessionEvidence(ctx context.Context, store *state.Store, repoRoot stri if err != nil { log.Debug().Err(err).Msg("could not collect code changes") } else { - attr := store.LoadAILineAttribution(sessionID) attribution.FilterGenerated(codeChanges, isGenerated) - attribution.Enrich(sessionID, attr.Files, codeChanges) + attribution.Enrich(sessionID, ledgers, sessionCommitLines(gitClient, repoRoot, commits, commitLines, log), codeChanges) result.Data.CodeChanges = codeChanges } } @@ -961,6 +1011,27 @@ func buildSessionEvidence(ctx context.Context, store *state.Store, repoRoot stri return sessions } +// sessionCommitLines returns the changed lines of each commit of a session, +// oldest first, with the sessions that the commit's record names. cache holds +// the lines already read, by SHA. A commit that cannot be read is left out. +func sessionCommitLines(gitClient tracegit.Client, repoRoot string, commits []*state.CommitRecord, cache map[string][]tracegit.FileLines, log zerolog.Logger) []attribution.CommitLines { + out := make([]attribution.CommitLines, 0, len(commits)) + for _, c := range commits { + lines, ok := cache[c.SHA] + if !ok { + var err error + if lines, err = gitClient.CommitLines(repoRoot, c.SHA); err != nil { + log.Debug().Err(err).Str("sha", c.SHA).Msg("could not read the lines of a commit") + continue + } + cache[c.SHA] = lines + } + out = append(out, attribution.CommitLines{SessionIDs: c.SessionIDs, Files: lines}) + } + + return out +} + // attachSessionEvidence adds the materials of each session to the attestation: // its spec and skill materials first, so that the session material can record // their digests, then the session itself. It returns the sessions that were diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go index 1fee2e421..d07b2a3d1 100644 --- a/app/cli/pkg/action/trace_hook_handler_test.go +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -295,6 +295,12 @@ func (f *fakeBranchClient) CodeChangesForCommits(string, []string) (*aicodingses return &aicodingsession.CodeChanges{}, nil } +func (f *fakeBranchClient) CommitLines(string, string) ([]tracegit.FileLines, error) { + return nil, nil +} + +func (f *fakeBranchClient) FileAt(string, string, string) ([]byte, error) { return nil, nil } + func (f *fakeBranchClient) CommitHeadInfo(string) (string, string, error) { return "", "", nil } func (f *fakeBranchClient) HeadAuthorTime(string) (time.Time, error) { return time.Time{}, nil } func (f *fakeBranchClient) GeneratedMatcher(string) func(string) bool { @@ -1213,8 +1219,15 @@ func buildSessionCodeChanges(t *testing.T, store *state.Store, dir, sha, session client := tracegit.NewGoGitClient() cc, err := client.CodeChangesForCommits(dir, []string{sha}) require.NoError(t, err) - attr := store.LoadAILineAttribution(sessionID) - attribution.Enrich(sessionID, attr.Files, cc) + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + ledgers := make(map[string]*state.AILineAttribution, len(attrs)) + for _, a := range attrs { + ledgers[a.SessionID] = a + } + commits := []*state.CommitRecord{{SHA: sha, SessionIDs: []string{sessionID}}} + lines := sessionCommitLines(client, dir, commits, map[string][]tracegit.FileLines{}, zerolog.Nop()) + attribution.Enrich(sessionID, ledgers, lines, cc) return cc } diff --git a/app/cli/pkg/action/trace_line_attribution_test.go b/app/cli/pkg/action/trace_line_attribution_test.go new file mode 100644 index 000000000..c5bf8fbe0 --- /dev/null +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -0,0 +1,361 @@ +// +// 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 action + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + 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" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The tests below follow the cases of spec issue-3639 through the real git +// hooks: an agent edit goes through recordFileEdit, as the agent hook does, +// and each commit through the commit-msg and post-commit handlers. + +const lineSession = "sess-lines" + +// lineRepo is a repository with trace state, driven like a user and an agent +// would drive it. +type lineRepo struct { + t *testing.T + dir string + store *state.Store +} + +func newLineRepo(t *testing.T) *lineRepo { + t.Helper() + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + return &lineRepo{t: t, dir: dir, store: store} +} + +// numbered returns n lines named prefix1..prefixN, newline terminated. +func numbered(prefix string, n int) []string { + out := make([]string, n) + for i := range out { + out[i] = prefix + strings.Repeat("x", i+1) + } + + return out +} + +func content(lines []string) string { + return strings.Join(lines, "\n") + "\n" +} + +// agentWrite writes a file as an agent edit of sessionID. +func (r *lineRepo) agentWrite(sessionID, file, text string) { + r.t.Helper() + path := filepath.Join(r.dir, file) + before, err := os.ReadFile(path) + if err != nil { + before = nil + } + require.NoError(r.t, os.WriteFile(path, []byte(text), 0600)) + recordFileEdit(r.store, sessionID, file, before, []byte(text), zerolog.Nop()) +} + +// humanWrite writes a file outside any agent. +func (r *lineRepo) humanWrite(file, text string) { + r.t.Helper() + require.NoError(r.t, os.WriteFile(filepath.Join(r.dir, file), []byte(text), 0600)) +} + +// commit stages files and commits them through the trace hooks. It returns +// the commit message, trailer included. +func (r *lineRepo) commit(subject string, files ...string) string { + r.t.Helper() + msg := filepath.Join(r.t.TempDir(), "msg") + require.NoError(r.t, os.WriteFile(msg, []byte(subject+"\n"), 0600)) + runGit(r.t, r.dir, append([]string{"add"}, files...)...) + require.NoError(r.t, handleCommitMsg(msg, zerolog.Nop())) + runGit(r.t, r.dir, "commit", "-F", msg) + require.NoError(r.t, handlePostCommit(r.t.Context(), zerolog.Nop())) + + got, err := os.ReadFile(msg) + require.NoError(r.t, err) + + return string(got) +} + +// sessionChanges runs the push pipeline for one session over the commits that +// name it, and returns its code changes. +func (r *lineRepo) sessionChanges(sessionID string) *aicodingsession.CodeChanges { + r.t.Helper() + records, err := r.store.LoadAllCommitRecords() + require.NoError(r.t, err) + commits := sessionCommitGroups(records, nil, false)[sessionID] + + shas := make([]string, 0, len(commits)) + for _, c := range commits { + shas = append(shas, c.SHA) + } + + client := tracegit.NewGoGitClient() + cc, err := client.CodeChangesForCommits(r.dir, shas) + require.NoError(r.t, err) + + attrs, err := r.store.LoadAllAILineAttributions() + require.NoError(r.t, err) + ledgers := make(map[string]*state.AILineAttribution, len(attrs)) + for _, a := range attrs { + ledgers[a.SessionID] = a + } + attribution.Enrich(sessionID, ledgers, sessionCommitLines(client, r.dir, commits, map[string][]tracegit.FileLines{}, zerolog.Nop()), cc) + + return cc +} + +type lineSplit struct{ aiAdded, aiRemoved, humanAdded, humanRemoved int } + +func fileSplitOf(t *testing.T, cc *aicodingsession.CodeChanges, path string) lineSplit { + t.Helper() + for _, f := range cc.Files { + if f.Path == path { + return lineSplit{f.AILinesAdded, f.AILinesRemoved, f.HumanLinesAdded, f.HumanLinesRemoved} + } + } + require.Failf(t, "file not in the code changes", "%s", path) + + return lineSplit{} +} + +func totalsOf(cc *aicodingsession.CodeChanges) lineSplit { + return lineSplit{cc.AILinesAdded, cc.AILinesRemoved, cc.HumanLinesAdded, cc.HumanLinesRemoved} +} + +// Case 1 of the spec: the AI writes the lines, a human changes some of them, +// and one commit holds the result (R-001). +func TestLineAttribution_HumanChangeBeforeCommit(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 10) + r.agentWrite(lineSession, "a.go", content(lines)) + lines[2], lines[7] = "human edit one", "human edit two" + r.humanWrite("a.go", content(lines)) + + assert.Contains(t, r.commit("feat: a", "a.go"), "Chainloop-Trace-Sessions: "+lineSession) + + cc := r.sessionChanges(lineSession) + want := lineSplit{aiAdded: 8, humanAdded: 2} + assert.Equal(t, want, fileSplitOf(t, cc, "a.go")) + assert.Equal(t, want, totalsOf(cc)) + + assert.NotContains(t, r.store.LoadAILineAttribution(lineSession).Pending, "a.go", + "a fully committed file leaves nothing pending") +} + +// R-002: a human change between two agent edits stays human, also when the +// next agent edit keeps it. +func TestLineAttribution_HumanChangeBetweenAgentEdits(t *testing.T) { + r := newLineRepo(t) + + r.agentWrite(lineSession, "a.go", "one\ntwo\nthree\n") + r.humanWrite("a.go", "one\nTWO by hand\nthree\n") + r.agentWrite(lineSession, "a.go", "one\nTWO by hand\nthree\nfour\n") + + open := r.store.LoadAILineAttribution(lineSession).Open["a.go"] + assert.Equal(t, 3, open.Added.Len(), "the replaced line closed before the second edit") + + r.commit("feat: a", "a.go") + assert.Equal(t, lineSplit{aiAdded: 3, humanAdded: 1}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// Case 3 of the spec, as far as milestone 1 goes: a later commit of the same +// session replaces AI lines by hand. The removed AI lines are human removals +// (R-005). The net numbers (R-003) come with milestone 2. +func TestLineAttribution_HumanRemovalIsHuman(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 5) + r.agentWrite(lineSession, "a.go", content(lines)) + r.commit("feat: a", "a.go") + + lines[1] = "human" + r.humanWrite("a.go", content(lines)) + r.agentWrite(lineSession, "b.go", "package b\n") + assert.Contains(t, r.commit("feat: b", "a.go", "b.go"), "Chainloop-Trace-Sessions: "+lineSession) + + assert.Equal(t, lineSplit{aiAdded: 5, humanAdded: 1, humanRemoved: 1}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// R-005: lines that the agent removed are AI removals. +func TestLineAttribution_AgentRemovalIsAI(t *testing.T) { + r := newLineRepo(t) + + r.humanWrite("a.go", "keep\ndrop one\ndrop two\n") + r.commit("chore: base", "a.go") + + r.agentWrite(lineSession, "a.go", "keep\n") + r.commit("feat: trim", "a.go") + + assert.Equal(t, lineSplit{aiRemoved: 2}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// R-006: a commit with only part of the AI lines of a file keeps the rest +// pending, and the next commit with them is credited to the session. +func TestLineAttribution_PartialCommitOfAFile(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 4) + r.agentWrite(lineSession, "a.go", content(lines)) + + // Stage only the first two lines, as `git add -p` would. + r.humanWrite("a.go", content(lines[:2])) + runGit(t, r.dir, "add", "a.go") + r.humanWrite("a.go", content(lines)) + msg := filepath.Join(t.TempDir(), "msg") + require.NoError(t, os.WriteFile(msg, []byte("feat: first half\n"), 0600)) + require.NoError(t, handleCommitMsg(msg, zerolog.Nop())) + runGit(t, r.dir, "commit", "-F", msg) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + attr := r.store.LoadAILineAttribution(lineSession) + require.Contains(t, attr.Pending, "a.go", "the unstaged AI lines keep the file pending") + assert.Equal(t, 2, attr.Open["a.go"].Added.Len()) + + assert.Contains(t, r.commit("feat: second half", "a.go"), "Chainloop-Trace-Sessions: "+lineSession) + assert.Equal(t, lineSplit{aiAdded: 4}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// A file whose AI lines a human replaced before the commit is settled by the +// commit, so a later human commit of the file is not credited to the session. +func TestLineAttribution_ReplacedLinesDoNotCreditLaterCommits(t *testing.T) { + r := newLineRepo(t) + + r.agentWrite(lineSession, "a.go", "one\ntwo\n") + r.agentWrite(lineSession, "b.go", "package b\n") + // The human rewrites a.go fully and commits b.go only. + r.humanWrite("a.go", "uno\ndos\n") + r.commit("feat: b", "b.go") + + assert.Contains(t, r.store.LoadAILineAttribution(lineSession).Pending, "a.go", + "a.go is not committed yet, so post-commit does not look at it") + + msg := r.commit("chore: a by hand", "a.go") + assert.Contains(t, msg, "Chainloop-Trace-Sessions: "+lineSession, + "the pending file still matches at the file level when it is committed") + assert.Equal(t, lineSplit{humanAdded: 2}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) + assert.NotContains(t, r.store.LoadAILineAttribution(lineSession).Pending, "a.go") + + r.humanWrite("a.go", "uno\ndos\ntres\n") + assert.NotContains(t, r.commit("chore: more", "a.go"), "Chainloop-Trace-Sessions") +} + +// A cherry-pick, or a rebase, replays a commit with a new SHA. The lines +// still match the ledger by content, so the AI split survives. +func TestLineAttribution_SurvivesCherryPick(t *testing.T) { + r := newLineRepo(t) + + runGit(t, r.dir, "checkout", "-b", "feature") + r.agentWrite(lineSession, "a.go", "one\ntwo\nthree\n") + r.humanWrite("a.go", "one\ntwo\nthree\nby hand\n") + r.commit("feat: a", "a.go") + picked := headSHA(t, r.dir) + + // main moves on, so the pick gets a new parent and a new SHA. + runGit(t, r.dir, "checkout", "main") + r.humanWrite("upstream.txt", "upstream\n") + r.commit("chore: upstream", "upstream.txt") + runGit(t, r.dir, "checkout", "-b", "rebased") + runGit(t, r.dir, "cherry-pick", picked) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + require.NotEqual(t, picked, headSHA(t, r.dir)) + + // Drop the feature branch and its record, as the post-push GC does. + runGit(t, r.dir, "branch", "-D", "feature") + live, err := tracegit.NewGoGitClient().LocalReachableSHAs(r.dir) + require.NoError(t, err) + require.NoError(t, r.store.GCOrphans(live)) + + assert.Equal(t, lineSplit{aiAdded: 3, humanAdded: 1}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// shellWrite runs a "shell command" of the session: the pre hook captures the +// worktree, run changes it, and the post hook records what changed. +func (r *lineRepo) shellWrite(sessionID string, run func()) { + r.t.Helper() + key := state.ShellCallKey{SessionID: sessionID, ToolUseID: "toolu"} + captureWorktreeSnapshot(r.store, r.dir, key, zerolog.Nop()) + run() + + sigs, err := r.store.LoadShellPreSignature(key) + require.NoError(r.t, err) + heads, err := r.store.LoadShellPreHeads(key) + require.NoError(r.t, err) + base := heads[r.dir] + require.NotEmpty(r.t, base, "the pre hook saves the HEAD commit") + recordRootChanges(r.store, r.dir, base, sigs[r.dir], sessionID, zerolog.Nop()) + r.store.DeleteShellPreSignature(key) +} + +// A shell command is diffed against HEAD, so its diff holds again the +// uncommitted lines of earlier edits. They are not recorded twice. +func TestLineAttribution_ShellDoesNotRecordOpenLinesAgain(t *testing.T) { + r := newLineRepo(t) + + r.shellWrite(lineSession, func() { r.humanWrite("a.go", "one\ntwo\n") }) + r.shellWrite(lineSession, func() { r.humanWrite("a.go", "one\ntwo\nthree\n") }) + + attr := r.store.LoadAILineAttribution(lineSession) + assert.Equal(t, 3, attr.Written["a.go"].Len()) + assert.Equal(t, 3, attr.Open["a.go"].Added.Len()) + assert.Equal(t, 3, attr.GeneratedAdded) + + r.commit("feat: a", "a.go") + assert.Equal(t, lineSplit{aiAdded: 3}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// A shell command that writes a file and commits it diffs against the HEAD +// from before the command, so its lines are still recorded. +func TestLineAttribution_ShellCommandThatCommits(t *testing.T) { + r := newLineRepo(t) + + r.shellWrite(lineSession, func() { + r.humanWrite("a.go", "one\ntwo\n") + runGit(t, r.dir, "add", "a.go") + runGit(t, r.dir, "commit", "-m", "feat: a") + }) + + attr := r.store.LoadAILineAttribution(lineSession) + assert.Equal(t, 2, attr.Written["a.go"].Len()) +} + +// R-011: the ledger keeps the lines that every agent edit added and removed, +// also the ones that never reach a commit. +func TestLineAttribution_GeneratedLines(t *testing.T) { + r := newLineRepo(t) + + r.agentWrite(lineSession, "a.go", "one\ntwo\nthree\n") + r.agentWrite(lineSession, "a.go", "one\n") + r.agentWrite(lineSession, "scratch.go", "tmp\n") + require.NoError(t, os.Remove(filepath.Join(r.dir, "scratch.go"))) + + attr := r.store.LoadAILineAttribution(lineSession) + assert.Equal(t, 4, attr.GeneratedAdded) + assert.Equal(t, 2, attr.GeneratedRemoved) +} diff --git a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json index b3bf2d908..75d5a4e43 100644 --- a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json @@ -244,6 +244,22 @@ "items": { "type": "string" } + }, + "ai_lines_added": { + "type": "integer", + "description": "Lines added in this file attributed to AI" + }, + "ai_lines_removed": { + "type": "integer", + "description": "Lines removed in this file attributed to AI" + }, + "human_lines_added": { + "type": "integer", + "description": "Lines added in this file attributed to human" + }, + "human_lines_removed": { + "type": "integer", + "description": "Lines removed in this file attributed to human" } } } diff --git a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go index 0f7018a44..706b071ef 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -226,13 +226,24 @@ type LineRange struct { // FileChange represents a single file modification in the session. type FileChange struct { - Path string `json:"path"` - Status string `json:"status"` - LinesAdded int `json:"lines_added,omitempty"` - LinesRemoved int `json:"lines_removed,omitempty"` - Attribution string `json:"attribution,omitempty"` - LineRanges []LineRange `json:"line_ranges,omitempty"` - SessionIDs []string `json:"session_ids,omitempty"` + Path string `json:"path"` + Status string `json:"status"` + LinesAdded int `json:"lines_added,omitempty"` + LinesRemoved int `json:"lines_removed,omitempty"` + // Attribution is the file-level marker (ai or human). Platforms that + // predate the per-file split below derive the split from it. + Attribution string `json:"attribution,omitempty"` + LineRanges []LineRange `json:"line_ranges,omitempty"` + SessionIDs []string `json:"session_ids,omitempty"` + // AILinesAdded, AILinesRemoved, HumanLinesAdded and HumanLinesRemoved + // split the changed lines of the file between the session's agent and + // humans. The session totals in CodeChanges are their sums. A line that + // only another AI session of the same commit wrote or removed counts for + // neither side, so the sums can be lower than LinesAdded and LinesRemoved. + AILinesAdded int `json:"ai_lines_added,omitempty"` + AILinesRemoved int `json:"ai_lines_removed,omitempty"` + HumanLinesAdded int `json:"human_lines_added,omitempty"` + HumanLinesRemoved int `json:"human_lines_removed,omitempty"` } // CodeChanges summarizes code modifications made during the session.