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/2] 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 e419b1f2da337d09c2448097c30888599caeeef9 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 20 Oct 2026 09:55:09 +0200 Subject: [PATCH 2/2] 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 --- .../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 {