From 093fbe2eebd8b3fe37c4a646724ba106dbed132f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 10:34:54 +0200 Subject: [PATCH 1/3] feat(trace): attribute lines to their last writer by content Record a whitespace-insensitive fingerprint for each line that an agent edit adds and removes. Push matches committed lines against these fingerprints, so a human change to an AI line counts as human, and only lines that an agent edit removed count as AI removals. Lines changed outside the session's edits close before the next agent edit and at commit time. A commit closes only the AI lines it holds, so the rest of a file stays pending for the next commit. The ledger keeps the added and removed counts of every agent edit. Each file entry of the evidence gets an AI/human split. Implements milestone 1 of spec issue-3639 (R-001, R-002, R-005, R-006, R-011). Refs #3639 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 81312179-16f4-4c92-a45b-a104a593a498 --- app/cli/internal/trace/README.md | 8 +- app/cli/internal/trace/attribution/diff.go | 114 +++++-- app/cli/internal/trace/attribution/enrich.go | 230 +++++++++++-- .../internal/trace/attribution/enrich_test.go | 247 ++++++++++++-- app/cli/internal/trace/attribution/lines.go | 71 ++++ .../internal/trace/attribution/lines_test.go | 139 ++++++++ app/cli/internal/trace/git/git.go | 15 + app/cli/internal/trace/git/lines.go | 218 ++++++++++++ app/cli/internal/trace/git/lines_test.go | 98 ++++++ app/cli/internal/trace/state/ailines.go | 213 ++++++++++-- app/cli/internal/trace/state/ailines_test.go | 157 +++++++++ app/cli/internal/trace/state/lines.go | 117 +++++++ app/cli/internal/trace/state/state_test.go | 14 +- app/cli/pkg/action/trace_agent_hook.go | 79 +++-- app/cli/pkg/action/trace_hook_handler.go | 87 ++++- app/cli/pkg/action/trace_hook_handler_test.go | 17 +- .../pkg/action/trace_line_attribution_test.go | 311 ++++++++++++++++++ .../ai-coding-session-0.1.schema.json | 16 + .../aicodingsession/aicodingsession.go | 23 +- 19 files changed, 2030 insertions(+), 144 deletions(-) create mode 100644 app/cli/internal/trace/attribution/lines.go create mode 100644 app/cli/internal/trace/attribution/lines_test.go create mode 100644 app/cli/internal/trace/git/lines.go create mode 100644 app/cli/internal/trace/git/lines_test.go create mode 100644 app/cli/internal/trace/state/ailines_test.go create mode 100644 app/cli/internal/trace/state/lines.go create mode 100644 app/cli/pkg/action/trace_line_attribution_test.go diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index fa8d84a1d..9381a70d6 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -33,12 +33,14 @@ 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 (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. The content of the file at `HEAD` stands in for the content before the command, so uncommitted human lines of a file that the command changes count as AI. | Yes | Partial | Yes | Yes | 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, 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..a95540fac 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -17,44 +17,224 @@ 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. A line that matches another session of the same commit counts for +// neither: 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 +} + +// 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..fb07c31ba 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,164 @@ 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 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..d23a98135 --- /dev/null +++ b/app/cli/internal/trace/git/lines.go @@ -0,0 +1,218 @@ +// +// 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" + "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 + if parent, err := commit.Parents().Next(); err == nil { + 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) + patch := gitOutput(repoRoot, "-c", "core.quotePath=false", "diff", "--no-color", "--no-ext-diff", + "--no-renames", "-U0", base, sha) + + 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. + if !inHunk { + switch { + case strings.HasPrefix(line, "--- a/"): + cur.Path = strings.TrimPrefix(line, "--- a/") + case strings.HasPrefix(line, "+++ b/"): + cur.Path = strings.TrimPrefix(line, "+++ b/") + 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 git cannot +// read it. +func (c *ExecClient) FileAt(repoRoot, sha, path string) ([]byte, error) { + out, err := gitRawOutput(repoRoot, "cat-file", "blob", sha+":"+path) + if err != nil { + return nil, nil //nolint:nilerr // a path the commit does not hold fails the same way + } + + 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..2341f9aae --- /dev/null +++ b/app/cli/internal/trace/git/lines_test.go @@ -0,0 +1,98 @@ +// +// 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("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"}}, + }, 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") + }) + }) + } +} + +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..9909339d0 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,69 @@ 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. +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) + } + } + o.Added.Add(lines.Added...) + + 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 +160,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 +199,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 +219,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 +288,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.markConsumedAt(s.LoadAILineAttribution(sessionID).Pending, sessionID, files, sha) + return s.appendAILineEntries(sessionID, []aiLineEntry{{ + SessionID: sessionID, + File: filePath, + RecordedAt: time.Now().UTC().Format(time.RFC3339Nano), + Settled: &lines, + }}) } -// 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 { +// 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.markCommittedAt(s.LoadAILineAttribution(sessionID).Pending, sessionID, sha, settled) +} + +// 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..f6656f272 --- /dev/null +++ b/app/cli/internal/trace/state/ailines_test.go @@ -0,0 +1,157 @@ +// +// 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("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/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..8747ade96 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -796,8 +796,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 +803,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 @@ -852,16 +866,29 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st // 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. +// 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 HEAD stands in for it: lines the command did not +// touch are then not claimed, except uncommitted ones, which are claimed as +// the whole-file range always was. An untracked file counts as new. func recordRootChanges(store *state.Store, repoRoot string, before map[string]string, sessionID string, log zerolog.Logger) { - after, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) + 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 } + atHead := func(relPath string) []byte { + content, err := client.FileAt(repoRoot, "HEAD", filepath.ToSlash(relPath)) + if err != nil { + return nil + } + + return content + } + changed, deleted := attribution.ChangedPaths(before, after) for _, relPath := range changed { content, err := os.ReadFile(filepath.Join(repoRoot, relPath)) @@ -869,18 +896,18 @@ 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 HEAD): the + // file is then AI-touched with zero lines, as a deletion-only edit. + edit := attribution.ComputeEdit(atHead(relPath), content) + if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); 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 HEAD, matching the file-writing + // hook's treatment of deletions. + edit := attribution.ComputeEdit(atHead(relPath), nil) + if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record deletion failed") } } @@ -888,6 +915,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..c6bce491d --- /dev/null +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -0,0 +1,311 @@ +// +// 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")) +} + +// 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..c4b5d6150 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -226,13 +226,22 @@ 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. + 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. From 20cc8b7f030a6d13306a0beb521d67f2ea525a31 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 11:03:51 +0200 Subject: [PATCH 2/3] fix(trace): address review of line attribution - Shell commands diff against the HEAD commit from before the command, so a command that commits its own change still records its lines, and lines already open for the session are not recorded again. - Writing back a line that the session removed closes the removal, so an undo leaves nothing pending. - The git clients return read errors instead of empty results, only a root commit has no parent, and the exec client trims the tab that git adds to header paths with a space. - Document how lines of another session in the same commit count, and mark shell attribution as partial in the Trace README. Refs #3639 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 81312179-16f4-4c92-a45b-a104a593a498 --- app/cli/internal/trace/README.md | 5 +- app/cli/internal/trace/attribution/enrich.go | 40 ++++++++++++++- .../internal/trace/attribution/enrich_test.go | 23 +++++++++ app/cli/internal/trace/git/lines.go | 34 ++++++++++--- app/cli/internal/trace/git/lines_test.go | 10 ++++ app/cli/internal/trace/state/ailines.go | 10 +++- app/cli/internal/trace/state/ailines_test.go | 12 +++++ app/cli/internal/trace/state/snapshot.go | 46 ++++++++++++++++- app/cli/internal/trace/state/snapshot_test.go | 15 ++++++ app/cli/pkg/action/trace_agent_hook.go | 49 ++++++++++++++----- .../pkg/action/trace_line_attribution_test.go | 48 ++++++++++++++++++ .../aicodingsession/aicodingsession.go | 4 +- 12 files changed, 267 insertions(+), 29 deletions(-) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 9381a70d6..9bcb0d803 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -33,14 +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. 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 (spec issue-3639). | 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. The content of the file at `HEAD` stands in for the content before the command, so uncommitted human lines of a file that the command changes count as AI. | 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, 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/enrich.go b/app/cli/internal/trace/attribution/enrich.go index a95540fac..0d5fed4ae 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -35,8 +35,13 @@ type CommitLines struct { // 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. A line that matches another session of the same commit counts for -// neither: it is that session's work. All other lines are human. +// 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. @@ -225,6 +230,37 @@ func SettleCommit(open *state.OpenLines, committed tracegit.FileLines, worktree 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). diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go index fb07c31ba..745a8fae3 100644 --- a/app/cli/internal/trace/attribution/enrich_test.go +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -327,6 +327,29 @@ func TestSettleCommit(t *testing.T) { } } +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")...) diff --git a/app/cli/internal/trace/git/lines.go b/app/cli/internal/trace/git/lines.go index d23a98135..dd40e652a 100644 --- a/app/cli/internal/trace/git/lines.go +++ b/app/cli/internal/trace/git/lines.go @@ -18,6 +18,7 @@ package git import ( "errors" "fmt" + "io" "os/exec" "strings" @@ -45,7 +46,12 @@ func (c *GoGitClient) CommitLines(repoRoot, sha string) ([]FileLines, error) { // A root commit has no parent, and its diff is against the empty tree. var baseTree *object.Tree - if parent, err := commit.Parents().Next(); err == nil { + 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) } @@ -125,8 +131,12 @@ func (c *GoGitClient) FileAt(repoRoot, sha, path string) ([]byte, error) { // 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) - patch := gitOutput(repoRoot, "-c", "core.quotePath=false", "diff", "--no-color", "--no-ext-diff", + 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 @@ -150,13 +160,14 @@ func (c *ExecClient) CommitLines(repoRoot, sha string) ([]FileLines, error) { // 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. + // 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.TrimPrefix(line, "--- a/") + cur.Path = strings.TrimSuffix(strings.TrimPrefix(line, "--- a/"), "\t") case strings.HasPrefix(line, "+++ b/"): - cur.Path = strings.TrimPrefix(line, "+++ b/") + cur.Path = strings.TrimSuffix(strings.TrimPrefix(line, "+++ b/"), "\t") case strings.HasPrefix(line, "Binary files "): binary = true case strings.HasPrefix(line, "@@"): @@ -179,12 +190,19 @@ func (c *ExecClient) CommitLines(repoRoot, sha string) ([]FileLines, error) { return out, nil } -// FileAt returns the content of path at the commit, or nil when git cannot -// read it. +// 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, nil //nolint:nilerr // a path the commit does not hold fails the same way + return nil, fmt.Errorf("read %s at %s: %w", path, sha, err) } return out, nil diff --git a/app/cli/internal/trace/git/lines_test.go b/app/cli/internal/trace/git/lines_test.go index 2341f9aae..d72068de9 100644 --- a/app/cli/internal/trace/git/lines_test.go +++ b/app/cli/internal/trace/git/lines_test.go @@ -46,6 +46,7 @@ func TestCommitLinesAndFileAt(t *testing.T) { 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") @@ -73,6 +74,7 @@ func TestCommitLinesAndFileAt(t *testing.T) { {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) }) @@ -88,6 +90,14 @@ func TestCommitLinesAndFileAt(t *testing.T) { 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) }) }) } diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go index 9909339d0..b8d90938c 100644 --- a/app/cli/internal/trace/state/ailines.go +++ b/app/cli/internal/trace/state/ailines.go @@ -123,7 +123,9 @@ func (a *AILineAttribution) settle(file string, set *LineSet) { // 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. +// 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...) @@ -135,7 +137,11 @@ func (a *AILineAttribution) applyEdit(entry *aiLineEntry) { o.Removed.Add(fp) } } - o.Added.Add(lines.Added...) + for _, fp := range lines.Added { + if !o.Removed.Take(fp) { + o.Added.Add(fp) + } + } a.GeneratedAdded += entry.LinesAdded a.GeneratedRemoved += entry.LinesRemoved diff --git a/app/cli/internal/trace/state/ailines_test.go b/app/cli/internal/trace/state/ailines_test.go index f6656f272..3540057c9 100644 --- a/app/cli/internal/trace/state/ailines_test.go +++ b/app/cli/internal/trace/state/ailines_test.go @@ -79,6 +79,18 @@ func TestAILineAttribution_Lines(t *testing.T) { 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))) diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 2be3411d6..6772f4bd1 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 result is +// empty. +func (s *Store) LoadShellPreHeads(key ShellCallKey) map[string]string { + data, err := os.ReadFile(s.shellPreHeadsPath(key)) + if err != nil { + return nil + } + + var heads map[string]string + if err := json.Unmarshal(data, &heads); err != nil { + return nil + } + + return heads } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index ef262d656..4b5882408 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -99,6 +99,21 @@ func TestShellPreSignatureSlots(t *testing.T) { } } +func TestShellPreHeads(t *testing.T) { + store := NewGitStore(t.TempDir()) + key := ShellCallKey{SessionID: shellSessionID, ToolUseID: "toolu_1"} + + assert.Empty(t, store.LoadShellPreHeads(key), "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)) + assert.Equal(t, heads, store.LoadShellPreHeads(key)) + + store.DeleteShellPreSignature(key) + assert.Empty(t, store.LoadShellPreHeads(key), "the heads go with the signature") +} + // 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/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 8747ade96..62503aaee 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 @@ -847,8 +856,16 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st return } defer store.DeleteShellPreSignature(key) + heads := store.LoadShellPreHeads(key) for root, rootBefore := range before { + // A pre hook from an older CLI saved no HEAD: the current HEAD + // stands in for it. + base := heads[root] + if base == "" { + base = "HEAD" + } + rootStore := store if !sameDir(root, repoRoot) { s, r, err := state.LocateFrom(root) @@ -860,7 +877,7 @@ 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) } } @@ -869,10 +886,13 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st // 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 HEAD stands in for it: lines the command did not -// touch are then not claimed, except uncommitted ones, which are claimed as -// the whole-file range always was. An untracked file counts as new. -func recordRootChanges(store *state.Store, repoRoot string, before map[string]string, sessionID string, log zerolog.Logger) { +// 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 { @@ -880,8 +900,8 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st return } - atHead := func(relPath string) []byte { - content, err := client.FileAt(repoRoot, "HEAD", filepath.ToSlash(relPath)) + atBase := func(relPath string) []byte { + content, err := client.FileAt(repoRoot, base, filepath.ToSlash(relPath)) if err != nil { return nil } @@ -889,6 +909,11 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st 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)) @@ -896,18 +921,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 diff finds nothing (created empty, or restored to HEAD): the + // the diff finds nothing (created empty, or restored to base): the // file is then AI-touched with zero lines, as a deletion-only edit. - edit := attribution.ComputeEdit(atHead(relPath), content) - if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { + 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 { - // The removed lines are the file at HEAD, matching the file-writing + // The removed lines are the file at base, matching the file-writing // hook's treatment of deletions. - edit := attribution.ComputeEdit(atHead(relPath), nil) - if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { + if err := record(relPath, attribution.ComputeEdit(atBase(relPath), nil)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record deletion failed") } } diff --git a/app/cli/pkg/action/trace_line_attribution_test.go b/app/cli/pkg/action/trace_line_attribution_test.go index c6bce491d..1638cecc4 100644 --- a/app/cli/pkg/action/trace_line_attribution_test.go +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -295,6 +295,54 @@ func TestLineAttribution_SurvivesCherryPick(t *testing.T) { 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) + base := r.store.LoadShellPreHeads(key)[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) { diff --git a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go index c4b5d6150..706b071ef 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -237,7 +237,9 @@ type FileChange struct { 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. + // 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"` From 5d9671a4005a54300a1198fb795aceabb1933812 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 11:37:39 +0200 Subject: [PATCH 3/3] fix(trace): report unreadable pre-command HEAD commits LoadShellPreHeads returns its error. The post hook treats a missing file, written by a pre hook of an older CLI, as no saved HEAD, and logs any other read or parse failure before it falls back to the current HEAD. Refs #3639 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 81312179-16f4-4c92-a45b-a104a593a498 --- app/cli/internal/trace/state/snapshot.go | 12 ++++++------ app/cli/internal/trace/state/snapshot_test.go | 16 +++++++++++++--- app/cli/pkg/action/trace_agent_hook.go | 9 ++++++--- .../pkg/action/trace_line_attribution_test.go | 4 +++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 6772f4bd1..5160340ff 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -156,18 +156,18 @@ func (s *Store) SaveShellPreHeads(key ShellCallKey, heads map[string]string) err } // LoadShellPreHeads loads the HEAD commits saved before a shell command. A -// command whose pre hook ran on an older CLI has none, and the result is -// empty. -func (s *Store) LoadShellPreHeads(key ShellCallKey) map[string]string { +// 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 + return nil, err } var heads map[string]string if err := json.Unmarshal(data, &heads); err != nil { - return nil + return nil, fmt.Errorf("parse shell heads: %w", err) } - return heads + return heads, nil } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index 4b5882408..69945fff4 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -103,15 +103,25 @@ func TestShellPreHeads(t *testing.T) { store := NewGitStore(t.TempDir()) key := ShellCallKey{SessionID: shellSessionID, ToolUseID: "toolu_1"} - assert.Empty(t, store.LoadShellPreHeads(key), "a command without saved heads has none") + _, 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)) - assert.Equal(t, heads, store.LoadShellPreHeads(key)) + got, err := store.LoadShellPreHeads(key) + require.NoError(t, err) + assert.Equal(t, heads, got) store.DeleteShellPreSignature(key) - assert.Empty(t, store.LoadShellPreHeads(key), "the heads go with the signature") + _, 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 diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 62503aaee..50bca42b6 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -856,11 +856,14 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st return } defer store.DeleteShellPreSignature(key) - heads := store.LoadShellPreHeads(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 { - // A pre hook from an older CLI saved no HEAD: the current HEAD - // stands in for it. base := heads[root] if base == "" { base = "HEAD" diff --git a/app/cli/pkg/action/trace_line_attribution_test.go b/app/cli/pkg/action/trace_line_attribution_test.go index 1638cecc4..c5bf8fbe0 100644 --- a/app/cli/pkg/action/trace_line_attribution_test.go +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -305,7 +305,9 @@ func (r *lineRepo) shellWrite(sessionID string, run func()) { sigs, err := r.store.LoadShellPreSignature(key) require.NoError(r.t, err) - base := r.store.LoadShellPreHeads(key)[r.dir] + 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)