Parse git-ai authorship note format
ober
77e0226be82cfef6cf5e7a8319bb67cd695671b2
--- a/GAPS.md +++ b/GAPS.md @@ -295,6 +295,12 @@ Acceptance criteria: - Preserve unsupported fields in a bounded raw/extra object or warning. - Add malformed and alternate-format fixtures. +Status: implemented for git-ai `authorship/3.0.0` text+JSON commit notes, +including prompt/session hash attestations, external session IDs, quoted file +paths, simple ranges and range lists, unsupported metadata warning, and smoke +fixtures. Batch note lookup remains tracked by G-031. Recovered attribution and +broader non-note inference remain tracked by G-032. + ### G-031: Batch note resolution missing `git-ai` can resolve notes in batches for commit/blob objects. This project --- a/main-binary.ss +++ b/main-binary.ss @@ -214,12 +214,102 @@ (let ([parsed (try-result (with-input-from-string text read-json))]) (if (ok? parsed) (unwrap parsed) #f))) (def (parse-note-object note) - (if (string-empty? note) #f (parse-json-object note))) + (if (string-empty? note) + #f + (let ([json-note (parse-json-object note)]) + (if json-note json-note (parse-git-ai-note-object note))))) (def (hash-get/default obj key fallback) (if (hash-table? obj) (hash-ref obj key fallback) fallback)) +(def git-ai-attestations-key "__git_ai_attestations") +(def (git-ai-unquote-path line) + (let ([n (string-length line)]) + (if (and (>= n 2) + (char=? (string-ref line 0) #\") + (char=? (string-ref line (- n 1)) #\")) + (substring line 1 (- n 1)) + line))) +(def (git-ai-parse-range part) + (let ([idx (string-contains part "-")]) + (if idx + (list (parse-int (substring part 0 idx) 0) + (parse-int (substring part (+ idx 1) (string-length part)) 0)) + (let ([n (parse-int part 0)]) (list n n))))) +(def (git-ai-parse-ranges ranges) + (map git-ai-parse-range + (filter (lambda (part) (not (blank? part))) (string-split ranges #\,)))) +(def (git-ai-entry-rows path entry-line) + (let* ([parts (filter (lambda (part) (not (blank? part))) (string-split (string-trim entry-line) #\space))] + [hash (safe-ref parts 0 "")] + [ranges (safe-ref parts 1 "")]) + (map (lambda (pair) + (list (cons 'hash hash) + (cons 'path path) + (cons 'start (safe-ref pair 0 0)) + (cons 'end (safe-ref pair 1 0)))) + (git-ai-parse-ranges ranges)))) +(def (git-ai-note-sections note) + (let loop ([xs (split-lines note)] [before '()]) + (cond [(null? xs) #f] + [(same-public-string? (car xs) "---") (list (reverse before) (cdr xs))] + [else (loop (cdr xs) (cons (car xs) before))]))) +(def (git-ai-attestation-rows attestation-lines) + (let loop ([xs attestation-lines] [path ""] [out '()]) + (cond [(null? xs) (reverse out)] + [(blank? (car xs)) (loop (cdr xs) path out)] + [(string-prefix? " " (car xs)) + (loop (cdr xs) path (append (reverse (git-ai-entry-rows path (substring (car xs) 2 (string-length (car xs))))) out))] + [else (loop (cdr xs) (git-ai-unquote-path (string-trim (car xs))) out)]))) +(def (parse-git-ai-note-object note) + (let ([sections (git-ai-note-sections note)]) + (if sections + (let* ([attestation-lines (safe-ref sections 0 '())] + [json-lines (safe-ref sections 1 '())] + [obj (parse-json-object (string-join json-lines "\n"))]) + (if (and (hash-table? obj) + (same-public-string? (hash-get/default obj "schema_version" "") "authorship/3.0.0")) + (begin + (hash-put! obj git-ai-attestations-key (git-ai-attestation-rows attestation-lines)) + obj) + #f)) + #f))) +(def (git-ai-session-key hash) + (let ([idx (string-contains hash "::")]) + (if idx (substring hash 0 idx) hash))) +(def (git-ai-record-for-hash note-obj hash) + (cond [(string-prefix? "h_" hash) + (hash-get/default (hash-get/default note-obj "humans" #f) hash #f)] + [(string-prefix? "s_" hash) + (hash-get/default (hash-get/default note-obj "sessions" #f) (git-ai-session-key hash) #f)] + [else (hash-get/default (hash-get/default note-obj "prompts" #f) hash #f)])) +(def (git-ai-agent-field record field) + (hash-get/default (hash-get/default record "agent_id" #f) field "")) +(def (git-ai-row-attribution note-obj row) + (let* ([hash (alist-ref/default row 'hash "")] + [record (git-ai-record-for-hash note-obj hash)] + [human? (string-prefix? "h_" hash)] + [tool (if human? "known-human" (git-ai-agent-field record "tool"))] + [model (if human? "" (git-ai-agent-field record "model"))] + [session (if human? (hash-get/default record "author" hash) (git-ai-agent-field record "id"))]) + (list (cons 'source "refs/notes/ai") + (cons 'tool tool) + (cons 'model model) + (cons 'session session) + (cons 'path (alist-ref/default row 'path "")) + (cons 'start (alist-ref/default row 'start 0)) + (cons 'end (alist-ref/default row 'end 0)) + (cons 'format "git-ai-authorship/3.0.0") + (cons 'hash hash)))) +(def (git-ai-note-object? note-obj) + (and (hash-table? note-obj) + (list? (hash-get/default note-obj git-ai-attestations-key #f)))) + +(def (git-ai-note-warnings note-obj) + (if (git-ai-note-object? note-obj) + '("git-ai authorship note parsed; unsupported metadata preserved in recorded note excerpt") + '())) (def (note-line-attribution note-obj line-obj) (list (cons 'source "refs/notes/ai") @@ -232,11 +322,13 @@ (def (note-attributions note-obj) (if (hash-table? note-obj) - (let* ([lines (hash-get/default note-obj "lines" '())] + (let* ([git-ai-rows (hash-get/default note-obj git-ai-attestations-key #f)] + [lines (hash-get/default note-obj "lines" '())] [tool (hash-get/default note-obj "tool" "")] [model (hash-get/default note-obj "model" "")] [session (hash-get/default note-obj "session" "")]) - (cond [(list? lines) (map (lambda (line-obj) (note-line-attribution note-obj line-obj)) lines)] + (cond [(list? git-ai-rows) (map (lambda (row) (git-ai-row-attribution note-obj row)) git-ai-rows)] + [(list? lines) (map (lambda (line-obj) (note-line-attribution note-obj line-obj)) lines)] [(or (not (string-empty? tool)) (not (string-empty? model)) (not (string-empty? session))) (list (list (cons 'source "refs/notes/ai") (cons 'tool tool) (cons 'model model) (cons 'session session) (cons 'path "") (cons 'start 0) (cons 'end 0)))] @@ -1117,6 +1209,7 @@ (list (str "heuristics skipped below --min-lines " min-lines)) '()) (if (and (not (string-empty? note)) (not note-obj)) '("refs/notes/ai note is not supported JSON") '()) + (git-ai-note-warnings note-obj) (if (and (string-empty? note) (not metadata-only?)) '("no refs/notes/ai authorship note found") '()) (if (and metadata-only? heuristics-only?) '("metadata-only and heuristics-only were both requested") '()))) --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -11,7 +11,8 @@ normal_timing_fixture=$(mktemp -d) similarity_fixture=$(mktemp -d) baseline_fixture=$(mktemp -d) identity_fixture=$(mktemp -d) -trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture"' EXIT +git_ai_note_fixture=$(mktemp -d) +trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture" "$git_ai_note_fixture"' EXIT git -C "$fixture" init -q git -C "$fixture" config user.name "Human Dev" @@ -87,6 +88,72 @@ if printf '%s\n' "$json" | grep -q 'vendor/library.py'; then exit 1 fi +git -C "$git_ai_note_fixture" init -q +git -C "$git_ai_note_fixture" config user.name "Human Dev" +git -C "$git_ai_note_fixture" config user.email "human@example.test" +mkdir -p "$git_ai_note_fixture/src" "$git_ai_note_fixture/docs" +i=1 +while [ "$i" -le 12 ]; do + printf 'pub fn generated_%s() -> usize { %s }\n' "$i" "$i" >> "$git_ai_note_fixture/src/example.rs" + i=$((i + 1)) +done +printf 'first\nsecond\nthird\n' > "$git_ai_note_fixture/docs/my file.md" +git -C "$git_ai_note_fixture" add src/example.rs "docs/my file.md" +GIT_AUTHOR_DATE='2026-07-29T09:02:00-06:00' \ +GIT_COMMITTER_DATE='2026-07-29T09:02:00-06:00' \ + git -C "$git_ai_note_fixture" commit -q -m 'add git ai authored files' +git_ai_note_file="$provider_tmp/git-ai-note.txt" +cat > "$git_ai_note_file" <<'NOTE' +src/example.rs + c9883b0 1-3,5 + s_abcdef12345678::t_deadbeef000001 7-9 +"docs/my file.md" + c9883b0 1 +--- +{ + "schema_version": "authorship/3.0.0", + "git_ai_version": "development", + "base_commit_sha": "", + "prompts": { + "c9883b0": { + "agent_id": { + "tool": "cursor", + "id": "session_123", + "model": "claude-3-sonnet" + }, + "human_author": null, + "messages": [], + "total_additions": 0, + "total_deletions": 0, + "accepted_lines": 0 + } + }, + "sessions": { + "s_abcdef12345678": { + "agent_id": { + "tool": "claude", + "id": "external_session_9", + "model": "opus" + }, + "human_author": null + } + } +} +NOTE +git -C "$git_ai_note_fixture" notes --ref=ai add -F "$git_ai_note_file" HEAD +git_ai_json=$("$root/bin/jerboa-aigit" scan "$git_ai_note_fixture" --format json --count 1) +printf '%s\n' "$git_ai_json" | grep -q '"recorded_ai_note_present":true' +printf '%s\n' "$git_ai_json" | grep -q '"format":"git-ai-authorship/3.0.0"' +printf '%s\n' "$git_ai_json" | grep -q '"tool":"cursor","model":"claude-3-sonnet","session":"session_123","path":"src/example.rs","start":1,"end":3' +printf '%s\n' "$git_ai_json" | grep -q '"tool":"cursor","model":"claude-3-sonnet","session":"session_123","path":"src/example.rs","start":5,"end":5' +printf '%s\n' "$git_ai_json" | grep -q '"tool":"claude","model":"opus","session":"external_session_9","path":"src/example.rs","start":7,"end":9' +printf '%s\n' "$git_ai_json" | grep -q '"path":"docs/my file.md","start":1,"end":1' +printf '%s\n' "$git_ai_json" | grep -q 'git-ai authorship note parsed; unsupported metadata preserved in recorded note excerpt' +if printf '%s\n' "$git_ai_json" | grep -q 'refs/notes/ai note is not supported JSON'; then + echo "git-ai authorship note should not be reported as unsupported JSON" >&2 + exit 1 +fi + git -C "$identity_fixture" init -q git -C "$identity_fixture" config user.name "Human Decoder" git -C "$identity_fixture" config user.email "decodex@example.test"