From ff2a3392b49214e1eb184eb8d3c2a2b4cdb15e87 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 21:46:40 +0200 Subject: [PATCH 1/3] feat(trace): allow an attribution on each line range Open the AI coding session schema so that each line range can carry an optional attribution, ai or human. The CLI does not write it yet: a later release writes it, so readers have time to take the new schema (spec issue-3639, line ranges D-005). Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 9d041a37-66a6-476b-8571-897e21eefa5a --- .../ai-coding-session-0.1.schema.json | 4 ++ .../schemavalidators/schemavalidators_test.go | 44 +++++++++++++++++++ .../aicodingsession/aicodingsession.go | 4 ++ 3 files changed, 52 insertions(+) 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 75d5a4e43..fbfaf42ad 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 @@ -233,6 +233,10 @@ "end": { "type": "integer", "description": "End line number" + }, + "attribution": { + "type": "string", + "description": "Owner of the lines in the range: ai or human. A range with an attribution is a position in the file at the pushed commit. A range with no attribution is an AI range at edit time." } }, "additionalProperties": false diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 5bf0a51d2..f6b8a3080 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -445,6 +445,50 @@ func TestValidateAICodingSessionSpec(t *testing.T) { } } +// TestValidateAICodingSessionLineRanges covers code_changes.files[].line_ranges. +// A range can say if its lines are AI or human (spec issue-3639, line ranges +// D-005). A range with no attribution keeps its earlier meaning, so the +// property is optional. +func TestValidateAICodingSessionLineRanges(t *testing.T) { + withRange := func(t *testing.T, r map[string]any) map[string]any { + t.Helper() + f, err := os.ReadFile("./testdata/ai_coding_session_valid.json") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(f, &payload)) + payload["code_changes"] = map[string]any{ + "files": []any{ + map[string]any{"path": "main.go", "status": "modified", "line_ranges": []any{r}}, + }, + } + return payload + } + + testCases := []struct { + name string + r map[string]any + wantErr bool + }{ + {name: "accepts a range with no attribution", r: map[string]any{"start": 1, "end": 3}}, + {name: "accepts an AI range", r: map[string]any{"start": 1, "end": 3, "attribution": "ai"}}, + {name: "accepts a human range", r: map[string]any{"start": 4, "end": 4, "attribution": "human"}}, + {name: "rejects a non-string attribution", r: map[string]any{"start": 1, "end": 3, "attribution": 1}, wantErr: true}, + {name: "rejects an unknown property", r: map[string]any{"start": 1, "end": 3, "owner": "ai"}, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := schemavalidators.ValidateAICodingSession(withRange(t, tc.r), "") + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + func TestValidateSecurityContext(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go index 706b071ef..fbe4a7ae7 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -222,6 +222,10 @@ type GitContext struct { type LineRange struct { Start int `json:"start"` End int `json:"end"` + // Attribution is the owner of the lines, ai or human. A range with an + // attribution is a position in the file at the pushed commit. A range + // with no attribution is an AI range at edit time, as older CLIs write it. + Attribution string `json:"attribution,omitempty"` } // FileChange represents a single file modification in the session. From 677c71621e4f9671ff2815e15d70d00f715c2d3f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 19 Oct 2026 21:46:45 +0200 Subject: [PATCH 2/3] feat(trace): line ranges that point into the pushed code Each line range of a file is now a position in the file at the pushed commit, with an attribution, ai or human. The ranges cover the lines of the session that are still there, and agree with the net AI and human counts. A file with no blame keeps the AI ranges at edit time, with no attribution (spec issue-3639, line ranges R-001 to R-005). Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 9d041a37-66a6-476b-8571-897e21eefa5a --- app/cli/internal/trace/README.md | 3 +- app/cli/internal/trace/attribution/enrich.go | 35 ++++++- .../internal/trace/attribution/enrich_test.go | 46 ++++++++-- app/cli/internal/trace/e2e/checks.go | 1 + app/cli/internal/trace/e2e/checks_more.go | 23 +++++ .../pkg/action/trace_line_attribution_test.go | 91 +++++++++++++++++++ 6 files changed, 188 insertions(+), 11 deletions(-) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index cd9faccfd..fd878fd3a 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -56,11 +56,12 @@ Which lines of a commit the AI wrote. | Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Partial | Partial | Partial | Partial | | Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the sum of the removals of the session's commits (spec issue-3639). | Partial | Partial | Partial | Partial | | Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | +| Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution comes from an older CLI: it is an AI range at edit time (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | Why not Yes: - **Line attribution, partial commits and generated line counts, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits. -- **Net contribution, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too. +- **Net contribution and line ranges, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too. - **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. - **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. - **Shell changes attribution, Cursor:** the provider installs the `preToolUse` and `postToolUse` hooks for the `Shell` tool. These hooks are verified for the Cursor IDE only. It is not verified that the Cursor CLI sends them. diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go index 2628d7408..2a6b0f749 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -57,6 +57,12 @@ type CommitLines struct { // back to the sum of the added lines of each commit. Removed lines are always // the sum over the session's own commits. // +// The line ranges of a file in tip are the positions of the session's net +// added lines in the pushed commit, each range AI or human (spec issue-3639, +// line ranges R-001, R-002). They hold as many lines as the net counts +// (R-003). A file that tip does not hold keeps the AI ranges at edit time, +// with no attribution, as older CLIs write them (R-005). +// // 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, tip map[string][]tracegit.BlameLine, changes *aicodingsession.CodeChanges) { @@ -66,6 +72,7 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi } byPath := splitByPath(sessionID, self, ledgers, commits) + positioned := make(map[string][]aicodingsession.LineRange, len(tip)) for path, net := range netAdded(sessionID, self, ledgers, commits, tip) { s, ok := byPath[path] if !ok { @@ -73,6 +80,7 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi byPath[path] = s } s.aiAdded, s.humanAdded = net.aiAdded, net.humanAdded + positioned[path] = net.ranges } changes.AILinesAdded, changes.AILinesRemoved = 0, 0 @@ -90,6 +98,9 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi } else { f.Attribution = trace.AttributionHuman } + if ranges, ok := positioned[f.Path]; ok { + f.LineRanges = ranges + } switch s, ok := byPath[f.Path]; { case self.Legacy[f.Path]: @@ -115,6 +126,19 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi // fileSplit is the AI/human split of the changed lines of one file. type fileSplit struct { aiAdded, aiRemoved, humanAdded, humanRemoved int + // ranges are the positions of the net added lines in the pushed commit. + ranges []aicodingsession.LineRange +} + +// addLine adds line number n, owned by owner, to the ranges. A line next to +// the last range with the same owner extends it. +func (s *fileSplit) addLine(n int, owner string) { + if last := len(s.ranges) - 1; last >= 0 && s.ranges[last].End == n-1 && s.ranges[last].Attribution == owner { + s.ranges[last].End = n + return + } + + s.ranges = append(s.ranges, aicodingsession.LineRange{Start: n, End: n, Attribution: owner}) } // splitByPath matches the lines of each commit against the ledgers, and @@ -166,9 +190,9 @@ func splitByPath(sessionID string, self *state.AILineAttribution, ledgers map[st } // netAdded splits the lines of each file in tip that the session's commits -// wrote. A line from a commit outside the session is not the session's. A -// line from a joined commit counts as human when its replacing hunks added -// it, and not at all otherwise. +// wrote, and records the position of each one. A line from a commit outside +// the session is not the session's. A line from a joined commit counts as +// human when its replacing hunks added it, and not at all otherwise. func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[string]*state.AILineAttribution, commits []CommitLines, tip map[string][]tracegit.BlameLine) map[string]*fileSplit { bySHA := make(map[string]CommitLines, len(commits)) for _, c := range commits { @@ -183,7 +207,7 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin s := &fileSplit{} out[path] = s - for _, l := range lines { + for i, l := range lines { commit, ok := bySHA[l.SHA] if !ok { continue @@ -200,6 +224,7 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin } if pool.Take(fp) { s.humanAdded++ + s.addLine(i+1, trace.AttributionHuman) } continue @@ -208,9 +233,11 @@ func netAdded(sessionID string, self *state.AILineAttribution, ledgers map[strin switch { case written.take(self, path, fp): s.aiAdded++ + s.addLine(i+1, trace.AttributionAI) case written.takeAny(otherLedgers(sessionID, ledgers, commit.SessionIDs), path, fp): default: s.humanAdded++ + s.addLine(i+1, trace.AttributionHuman) } } } diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go index e7d38db62..6378215b7 100644 --- a/app/cli/internal/trace/attribution/enrich_test.go +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -120,6 +120,8 @@ func TestEnrich_Legacy(t *testing.T) { assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) assert.Equal(t, []string{testSession}, changes.Files[0].SessionIDs) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 5}}, changes.Files[0].LineRanges, + "a file with no blame keeps the ranges at edit time, with no attribution (R-005)") assert.Equal(t, 5, changes.AILinesAdded) assert.Equal(t, 2, changes.AILinesRemoved) assert.Equal(t, 0, changes.HumanLinesAdded) @@ -286,6 +288,12 @@ func TestEnrich_Net(t *testing.T) { return out } five := []string{"a", "b", "c", "d", "e"} + ai := func(start, end int) aicodingsession.LineRange { + return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionAI} + } + human := func(start, end int) aicodingsession.LineRange { + return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionHuman} + } ownCommit := func(sha string, added, removed []string) CommitLines { return CommitLines{SHA: sha, SessionIDs: []string{testSession}, Files: []tracegit.FileLines{{Path: testFile, Added: added, Removed: removed}}} } @@ -296,6 +304,9 @@ func TestEnrich_Net(t *testing.T) { commits []CommitLines tip map[string][]tracegit.BlameLine want split + // ranges are the positions of the session's lines at the pushed + // commit (line ranges spec R-001, R-002). + ranges []aicodingsession.LineRange }{ { // Case 3 of the spec: a later commit of the session replaces an @@ -306,8 +317,9 @@ func TestEnrich_Net(t *testing.T) { ownCommit(c1, five, nil), ownCommit(c2, []string{"C"}, []string{"c"}), }, - tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")}, - want: split{aiAdded: 4, humanAdded: 1, humanRemoved: 1}, + tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")}, + want: split{aiAdded: 4, humanAdded: 1, humanRemoved: 1}, + ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)}, }, { // Case 2 of the spec: a commit in no session replaces an AI line @@ -320,8 +332,9 @@ func TestEnrich_Net(t *testing.T) { {SHA: c2, Files: []tracegit.FileLines{{Path: testFile, Added: []string{"C", "x"}, Removed: []string{"c"}}}, Joined: map[string][]string{testFile: fps("C")}}, }, - tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e", c2+":x")}, - want: split{aiAdded: 4, humanAdded: 1}, + tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e", c2+":x")}, + want: split{aiAdded: 4, humanAdded: 1}, + ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)}, }, { // Case 4 of the spec: a commit of another session replaces the @@ -331,6 +344,7 @@ func TestEnrich_Net(t *testing.T) { commits: []CommitLines{ownCommit(c1, five, nil)}, tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c3+":C", c1+":d", c1+":e")}, want: split{aiAdded: 4}, + ranges: []aicodingsession.LineRange{ai(1, 2), ai(4, 5)}, }, { name: "a line of another session of the commit counts for neither", @@ -340,8 +354,9 @@ func TestEnrich_Net(t *testing.T) { }, commits: []CommitLines{{SHA: c1, SessionIDs: []string{testSession, otherSession}, Files: []tracegit.FileLines{{Path: testFile, Added: []string{"a", "b", "h"}}}}}, - tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c1+":h")}, - want: split{aiAdded: 1, humanAdded: 1}, + tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c1+":h")}, + want: split{aiAdded: 1, humanAdded: 1}, + ranges: []aicodingsession.LineRange{ai(1, 1), human(3, 3)}, }, { name: "a file that the pushed commit does not hold adds nothing", @@ -353,6 +368,16 @@ func TestEnrich_Net(t *testing.T) { tip: map[string][]tracegit.BlameLine{testFile: nil}, want: split{aiRemoved: 1, humanRemoved: 5}, }, + { + // D-003: the human lines of the session get ranges too, also in a + // file that no agent edit of the session touched. + name: "a file that only a human changed in the session has human ranges", + ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, "b.go", nil, nil)}, + commits: []CommitLines{ownCommit(c1, []string{"x", "y"}, nil)}, + tip: map[string][]tracegit.BlameLine{testFile: tipOf(c3+":k", c1+":x", c1+":y")}, + want: split{humanAdded: 2}, + ranges: []aicodingsession.LineRange{human(2, 3)}, + }, } for _, tc := range testCases { @@ -364,6 +389,15 @@ func TestEnrich_Net(t *testing.T) { f := changes.Files[0] assert.Equal(t, tc.want, split{f.AILinesAdded, f.AILinesRemoved, f.HumanLinesAdded, f.HumanLinesRemoved}) assert.Equal(t, tc.want, split{changes.AILinesAdded, changes.AILinesRemoved, changes.HumanLinesAdded, changes.HumanLinesRemoved}) + assert.Equal(t, tc.ranges, f.LineRanges) + + // R-003: the ranges hold as many lines as the net added lines. + covered := map[string]int{} + for _, r := range f.LineRanges { + covered[r.Attribution] += r.End - r.Start + 1 + } + assert.Equal(t, f.AILinesAdded, covered[trace.AttributionAI], "AI ranges agree with the AI count") + assert.Equal(t, f.HumanLinesAdded, covered[trace.AttributionHuman], "human ranges agree with the human count") }) } } diff --git a/app/cli/internal/trace/e2e/checks.go b/app/cli/internal/trace/e2e/checks.go index 09e569f89..6588c5735 100644 --- a/app/cli/internal/trace/e2e/checks.go +++ b/app/cli/internal/trace/e2e/checks.go @@ -97,6 +97,7 @@ var features = map[string]Feature{ "Line attribution": {Checks: []Check{{scenarioEditAndCommit, checkFileAttributedToAI("greeting.txt")}}}, "Shell changes attribution": {Checks: []Check{{scenarioEditAndCommit, checkFileAttributedToAI("shell.txt")}}}, "Partial commits": {Checks: []Check{{scenarioPartial, checkBothCommitsCredited}}}, + "Line ranges": {Checks: []Check{{scenarioEditAndCommit, checkLineRanges("greeting.txt", 3)}}}, "Generated line counts": { NotCovered: "neither the user nor the evidence can see the generated line counts yet", }, diff --git a/app/cli/internal/trace/e2e/checks_more.go b/app/cli/internal/trace/e2e/checks_more.go index 9f3fad2a6..78a585db3 100644 --- a/app/cli/internal/trace/e2e/checks_more.go +++ b/app/cli/internal/trace/e2e/checks_more.go @@ -28,6 +28,7 @@ import ( "strings" "time" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/pointer" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" @@ -183,3 +184,25 @@ func checkMediaKept(s *Session) error { } return nil } + +// checkLineRanges checks that the evidence gives the lines of a file that the +// agent wrote as one AI range, at their positions in the pushed commit. +func checkLineRanges(path string, lines int) func(s *Session) error { + return func(s *Session) error { + cc := evidence(s).CodeChanges + if cc == nil { + return errors.New("the evidence has no code changes") + } + want := []aicodingsession.LineRange{{Start: 1, End: lines, Attribution: trace.AttributionAI}} + for _, f := range cc.Files { + if f.Path != path { + continue + } + if !slices.Equal(f.LineRanges, want) { + return fmt.Errorf("%s: line ranges %+v, want %+v", path, f.LineRanges, want) + } + return nil + } + return fmt.Errorf("the code changes do not list %s", path) + } +} diff --git a/app/cli/pkg/action/trace_line_attribution_test.go b/app/cli/pkg/action/trace_line_attribution_test.go index 2d35ca355..653368dc1 100644 --- a/app/cli/pkg/action/trace_line_attribution_test.go +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -22,6 +22,7 @@ import ( "strings" "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" @@ -361,6 +362,96 @@ func TestLineAttribution_Case4OtherSessionReplaces(t *testing.T) { assert.Equal(t, lineSplit{humanAdded: 1, humanRemoved: 1}, fileSplitOf(t, got[other].changes, "a.go")) } +func rangesOf(t *testing.T, cc *aicodingsession.CodeChanges, path string) []aicodingsession.LineRange { + t.Helper() + for _, f := range cc.Files { + if f.Path == path { + return f.LineRanges + } + } + require.Failf(t, "file not in the code changes", "%s", path) + + return nil +} + +func aiRange(start, end int) aicodingsession.LineRange { + return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionAI} +} + +func humanRange(start, end int) aicodingsession.LineRange { + return aicodingsession.LineRange{Start: start, End: end, Attribution: trace.AttributionHuman} +} + +// Spec issue-3639 line ranges R-001 and R-004: the AI writes 100 lines, and a +// later commit replaces 20 of them by hand. Each push sends ranges at the +// positions of the pushed commit, with their owner. +func TestLineAttribution_RangesPointIntoPushedCommit(t *testing.T) { + testCases := []struct { + name string + // session is the session of the later commit, empty for none. + session string + }{ + {name: "later commit in no session (cases 2 and 5)"}, + {name: "later commit in the same session (case 3)", session: lineSession}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 100) + r.agentWrite(lineSession, "a.go", content(lines)) + r.commit("feat: a", "a.go") + first := r.push() + require.Contains(t, first, lineSession) + assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 100)}, rangesOf(t, first[lineSession].changes, "a.go")) + + for i := 40; i < 60; i++ { + lines[i] = "human " + lines[i] + } + r.humanWrite("a.go", content(lines)) + files := []string{"a.go"} + if tc.session != "" { + r.agentWrite(tc.session, "b.go", "package b\n") + files = append(files, "b.go") + } + r.commit("fix: by hand", files...) + + second := r.push() + require.Contains(t, second, lineSession, "the session is sent again") + cc := second[lineSession].changes + split := fileSplitOf(t, cc, "a.go") + assert.Equal(t, 80, split.aiAdded) + assert.Equal(t, 20, split.humanAdded) + assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 40), humanRange(41, 60), aiRange(61, 100)}, rangesOf(t, cc, "a.go")) + }) + } +} + +// Spec issue-3639 line ranges R-002: in case 4, each session gets ranges only +// for its own lines. +func TestLineAttribution_RangesOfTwoSessions(t *testing.T) { + r := newLineRepo(t) + const other = "sess-other" + + lines := numbered("ai", 5) + r.agentWrite(lineSession, "a.go", content(lines)) + r.commit("feat: a", "a.go") + r.push() + + lines[1] = "human in b" + r.humanWrite("a.go", content(lines)) + r.agentWrite(other, "b.go", "package b\n") + r.commit("feat: b", "a.go", "b.go") + + got := r.push() + require.Contains(t, got, lineSession) + require.Contains(t, got, other) + assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 1), aiRange(3, 5)}, rangesOf(t, got[lineSession].changes, "a.go")) + assert.Equal(t, []aicodingsession.LineRange{humanRange(2, 2)}, rangesOf(t, got[other].changes, "a.go")) + assert.Equal(t, []aicodingsession.LineRange{aiRange(1, 1)}, rangesOf(t, got[other].changes, "b.go")) +} + // R-005: lines that the agent removed are AI removals. func TestLineAttribution_AgentRemovalIsAI(t *testing.T) { r := newLineRepo(t) From 2edc07f5f42495bf870e8e22dea5993336ba2446 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 20 Oct 2026 09:55:09 +0200 Subject: [PATCH 3/3] test(schemavalidators): use constants for line range keys Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 9d041a37-66a6-476b-8571-897e21eefa5a Signed-off-by: Jose I. Paris --- .../schemavalidators/schemavalidators_test.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index f6b8a3080..ba2c15bb3 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -465,16 +465,22 @@ func TestValidateAICodingSessionLineRanges(t *testing.T) { return payload } + const ( + keyStart = "start" + keyEnd = "end" + keyAttribution = "attribution" + ) + testCases := []struct { name string r map[string]any wantErr bool }{ - {name: "accepts a range with no attribution", r: map[string]any{"start": 1, "end": 3}}, - {name: "accepts an AI range", r: map[string]any{"start": 1, "end": 3, "attribution": "ai"}}, - {name: "accepts a human range", r: map[string]any{"start": 4, "end": 4, "attribution": "human"}}, - {name: "rejects a non-string attribution", r: map[string]any{"start": 1, "end": 3, "attribution": 1}, wantErr: true}, - {name: "rejects an unknown property", r: map[string]any{"start": 1, "end": 3, "owner": "ai"}, wantErr: true}, + {name: "accepts a range with no attribution", r: map[string]any{keyStart: 1, keyEnd: 3}}, + {name: "accepts an AI range", r: map[string]any{keyStart: 1, keyEnd: 3, keyAttribution: "ai"}}, + {name: "accepts a human range", r: map[string]any{keyStart: 4, keyEnd: 4, keyAttribution: "human"}}, + {name: "rejects a non-string attribution", r: map[string]any{keyStart: 1, keyEnd: 3, keyAttribution: 1}, wantErr: true}, + {name: "rejects an unknown property", r: map[string]any{keyStart: 1, keyEnd: 3, "owner": "ai"}, wantErr: true}, } for _, tc := range testCases {