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..ba2c15bb3 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -445,6 +445,56 @@ 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 + } + + 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{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 { + 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.