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: