Parse AI note line attribution
ober
61a33da1c37e5f64b626dbe35f9cb05a1d69c2cd
--- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ JSON output includes: - author/committer-facing metadata - changed files, additions, deletions, and added line count - recorded AI note presence and excerpt +- structured `recorded_attribution` entries for supported Git AI note line + ranges (`source`, `tool`, `model`, `session`, `path`, `start`, `end`) - metadata hits - raw signal scores, weights, evidence, and limitations - aggregate score and verdict --- a/main-binary.ss +++ b/main-binary.ss @@ -9,7 +9,7 @@ (defstruct signal (name category score weight confidence reason evidence limitations)) (defstruct finding (commit parent author-name author-email time subject files additions deletions - added-lines note metadata signals score verdict warnings)) + added-lines note attribution metadata signals score verdict warnings)) (def raw-command-line (command-line)) (def cli-args @@ -101,6 +101,38 @@ (def (commit-message repo rev) (git repo (list "show" "-s" "--format=%B" rev))) (def (note-text repo rev) (string-trim (git repo (list "notes" "--ref=ai" "show" rev)))) +(def (parse-note-object note) + (if (string-empty? note) + #f + (let ([parsed (try-result (with-input-from-string note read-json))]) + (if (ok? parsed) (unwrap parsed) #f)))) + +(def (hash-get/default obj key fallback) + (if (hash-table? obj) + (hash-ref obj key fallback) + fallback)) + +(def (note-line-attribution note-obj line-obj) + (list (cons 'source "refs/notes/ai") + (cons 'tool (hash-get/default note-obj "tool" "")) + (cons 'model (hash-get/default note-obj "model" "")) + (cons 'session (hash-get/default note-obj "session" "")) + (cons 'path (hash-get/default line-obj "path" "")) + (cons 'start (hash-get/default line-obj "start" 0)) + (cons 'end (hash-get/default line-obj "end" 0)))) + +(def (note-attributions note-obj) + (if (hash-table? note-obj) + (let* ([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)] + [(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)))] + [else '()])) + '())) (def (parent-time repo parent) (if (string-empty? parent) 0 (parse-int (string-trim (git repo (list "show" "-s" "--format=%ct" parent))) 0))) @@ -326,12 +358,13 @@ [t (parse-int (safe-ref fields 4 "0") 0)]) (if (string=? e email) (loop (cdr xs) (cons t out)) (loop (cdr xs) out)))))) -(def (warnings files lines note metadata-only? heuristics-only? min-lines) +(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines) (append (if (null? files) '("no changed text files found or commit is unavailable") '()) (if (null? lines) '("no added UTF-8 patch lines available") '()) (if (and (> min-lines 0) (< (length lines) min-lines)) (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") '()) (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") '()))) @@ -342,7 +375,9 @@ [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")] [body (commit-message repo rev)] [files (changed-files repo rev file)] [paths (numstat-paths files)] [adds (numstat-adds files)] [dels (numstat-dels files)] [lines (added-lines repo rev file)] - [note (note-text repo rev)] [metadata (metadata-hits author-name author-email subject body note)] + [note (note-text repo rev)] [note-obj (parse-note-object note)] + [attribution (note-attributions note-obj)] + [metadata (metadata-hits author-name author-email subject body note)] [eligible? (or (= min-lines 0) (>= (length lines) min-lines))] [sim-pair (similarity-signal lines hashes)] [raw-signals (if eligible? @@ -353,8 +388,8 @@ [signals (if metadata-only? '() raw-signals)] [score (if metadata-only? 0.0 (aggregate-score signals))] [v (if heuristics-only? (verdict score '() "") (verdict score metadata note))]) - (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note metadata signals score v - (warnings files lines note metadata-only? heuristics-only? min-lines)) + (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution metadata signals score v + (warnings files lines note note-obj metadata-only? heuristics-only? min-lines)) (cadr sim-pair)))) (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?) @@ -405,6 +440,7 @@ (cons 'additions (finding-additions f)) (cons 'deletions (finding-deletions f)) (cons 'added_lines (finding-added-lines f)) (cons 'recorded_ai_note_present (not (string-empty? (finding-note f)))) (cons 'recorded_ai_note_excerpt (if (string-empty? (finding-note f)) "" (substring (finding-note f) 0 (min 400 (string-length (finding-note f)))))) + (cons 'recorded_attribution (finding-attribution f)) (cons 'metadata_hits (finding-metadata f)) (cons 'signals (map signal-json (finding-signals f))) (cons 'score (finding-score f)) (cons 'verdict (finding-verdict f)) (cons 'warnings (finding-warnings f)))) (def (report-json repo findings) --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -38,6 +38,7 @@ json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 2) printf '%s\n' "$json" | grep -q '"count":2' printf '%s\n' "$json" | grep -q '"verdict":"recorded-ai-authorship"' printf '%s\n' "$json" | grep -q '"recorded_ai_note_present":true' +printf '%s\n' "$json" | grep -q '"recorded_attribution":\[{"source":"refs/notes/ai","tool":"codex","model":"gpt-5","session":"","path":"src/generated.py","start":1,"end":360}\]' printf '%s\n' "$json" | grep -q '"metadata_hits":\["codex","openai"\]' printf '%s\n' "$json" | grep -q '"category":"code"' printf '%s\n' "$json" | grep -q '"category":"structure"' @@ -87,6 +88,13 @@ range_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --from "$bas printf '%s\n' "$range_json" | grep -q '"count":1' printf '%s\n' "$range_json" | grep -q "$head_rev" +git -C "$fixture" notes --ref=ai remove HEAD >/dev/null +git -C "$fixture" notes --ref=ai add -m 'not json' HEAD +malformed=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1) +printf '%s\n' "$malformed" | grep -q '"recorded_ai_note_present":true' +printf '%s\n' "$malformed" | grep -q '"recorded_attribution":\[\]' +printf '%s\n' "$malformed" | grep -q 'refs/notes/ai note is not supported JSON' + if "$root/bin/jerboa-aigit" scan "$fixture/nope" >/tmp/jerboa-aigit-invalid.out 2>&1; then echo "invalid repository path should fail" >&2 exit 1