Report binary and merge scan limits

ober

788067d69c71f51766c18c540977bfadd7debc65

diff --git a/Makefile b/Makefile
index 4f6fb1f..9c4fac5 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,8 @@
 JERBOA ?= jerboa
 BIN := bin/jerboa-aigit
+DIST_BIN := dist/jerboa-aigit
 
-.PHONY: all help test verify install clean
+.PHONY: all help test verify binary install clean
 
 all: help
 
@@ -10,7 +11,8 @@ help:
 	@echo ""
 	@echo "  make test     Run fixture smoke tests"
 	@echo "  make verify   Run syntax smoke plus fixture tests"
-	@echo "  make install  Install wrapper to ~/.local/bin/sniff-ai"
+	@echo "  make binary   Build native binary into dist/jerboa-aigit"
+	@echo "  make install  Install wrapper to ~/.local/bin/jerboa-aigit"
 	@echo "  make clean    Remove local build artifacts"
 
 $(BIN): bin/jerboa-aigit
@@ -22,6 +24,11 @@ verify:
 	@$(JERBOA) main-binary.ss --help >/dev/null
 	@tests/fixture-smoke.sh
 
+binary:
+	@mkdir -p dist
+	@$(JERBOA) jerbuild binary main-binary.ss $(DIST_BIN)
+	@$(DIST_BIN) --help >/dev/null
+
 install:
 	@mkdir -p "$(HOME)/.local/bin"
 	@cp bin/jerboa-aigit "$(HOME)/.local/bin/jerboa-aigit"
@@ -30,4 +37,3 @@ install:
 
 clean:
 	@rm -rf dist tmp
-
diff --git a/README.md b/README.md
index c79d73b..c73257a 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,10 @@ Resource limits default to 500 changed files per commit, 20,000 added lines
 analyzed per commit, and 50,000 bytes per AI note. When a limit is hit, output
 keeps the bounded data and includes a warning.
 
+Warnings also call out root commits with no parent baseline, merge commits where
+the scanner intentionally uses the first-parent diff, and binary file changes
+that are skipped by text-line heuristics.
+
 ## What It Reads
 
 The scanner runs Git commands against the requested repository using fixed
@@ -97,6 +101,7 @@ disciplined commit conventions, bot commits, and large refactors.
 ```bash
 make test
 make verify
+make binary
 ```
 
 All Jerboa source is in `main-binary.ss`. Per `AGENTS.md`, edit `.ss` files only
diff --git a/main-binary.ss b/main-binary.ss
index 9de9542..0b5d271 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -177,8 +177,20 @@
 (def (parent-time repo parent)
   (if (string-empty? parent) 0 (parse-int (string-trim (git repo (list "show" "-s" "--format=%ct" parent))) 0)))
 
+(def (parent-list parents)
+  (filter (lambda (x) (not (blank? x))) (string-split parents #\space)))
+
+(def (parent-count parents)
+  (length (parent-list parents)))
+
+(def (root-commit? parents)
+  (= (parent-count parents) 0))
+
+(def (merge-commit? parents)
+  (> (parent-count parents) 1))
+
 (def (first-parent parents)
-  (let ([parts (filter (lambda (x) (not (blank? x))) (string-split parents #\space))])
+  (let ([parts (parent-list parents)])
     (if (pair? parts) (car parts) "")))
 
 (def (parse-numstat line)
@@ -187,22 +199,39 @@
          [dels (parse-int (safe-ref parts 1 "0") 0)]
          [path (safe-ref parts 2 "")])
     (list path adds dels)))
+(def (numstat-lines repo rev file)
+  (let ([args (append (list "show" "--format=" "--numstat" "--first-parent" rev)
+                      (pathspec-args file))])
+    (filter (lambda (line) (not (blank? line)))
+            (split-lines (git repo args)))))
+
+(def (numstat-line-path line)
+  (safe-ref (split-tabs line) 2 ""))
+
+(def (selected-numstat-lines lines explicit-file)
+  (if explicit-file
+      lines
+      (filter (lambda (line) (not (excluded-path? (numstat-line-path line)))) lines)))
+
+(def (binary-numstat-line? line)
+  (let ([parts (split-tabs line)])
+    (and (>= (length parts) 3)
+         (same-public-string? (safe-ref parts 0 "") "-")
+         (same-public-string? (safe-ref parts 1 "") "-"))))
+
+(def (text-numstat-line? line)
+  (not (binary-numstat-line? line)))
+
+(def (binary-file-count repo rev file)
+  (count-where binary-numstat-line? (selected-numstat-lines (numstat-lines repo rev file) file)))
 
 (def (changed-files repo rev file)
-  (let* ([args (append (list "show" "--format=" "--numstat" "--first-parent" rev)
-                       (pathspec-args file))]
-         [records (map parse-numstat
-                       (filter (lambda (line) (not (blank? line)))
-                               (split-lines (git repo args))))])
+  (let* ([lines (numstat-lines repo rev file)]
+         [text-lines (filter text-numstat-line? lines)]
+         [records (map parse-numstat text-lines)])
     (selected-file-records records file)))
 (def (changed-file-count repo rev file)
-  (let* ([args (append (list "show" "--format=" "--numstat" "--first-parent" rev)
-                       (pathspec-args file))]
-         [records (map parse-numstat
-                       (filter (lambda (line) (not (blank? line)))
-                               (split-lines (git repo args))))]
-         [selected (if file records (filter (lambda (record) (not (excluded-path? (car record)))) records))])
-    (length selected)))
+  (length (selected-numstat-lines (numstat-lines repo rev file) file)))
 
 (def (numstat-adds files) (sum (map cadr files)))
 (def (numstat-dels files) (sum (map caddr files)))
@@ -468,8 +497,19 @@
                [t (parse-int (safe-ref fields 4 "0") 0)])
           (if (same-public-string? e author-id) (loop (cdr xs) (cons t out)) (loop (cdr xs) out))))))
 
-(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count)
-  (append (if (null? files) '("no changed text files found or commit is unavailable") '())
+(def (commit-shape-warnings parents)
+  (append (if (root-commit? parents) '("root commit; no parent diff baseline") '())
+          (if (merge-commit? parents) '("merge commit; scanner uses first-parent diff") '())))
+
+(def (binary-change-warnings binary-count)
+  (if (> binary-count 0)
+      (list (str "binary file changes skipped: " binary-count))
+      '()))
+
+(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents binary-count)
+  (append (commit-shape-warnings parents)
+          (binary-change-warnings binary-count)
+          (if (null? files) '("no changed text files found or commit is unavailable") '())
           (if (null? lines) '("no added UTF-8 patch lines available") '())
           (limit-warning "files" file-count (scan-config-max-files current-config))
           (limit-warning "added lines" line-count (scan-config-max-added-lines current-config))
@@ -487,6 +527,7 @@
          [author-name (safe-ref fields 2 "")] [author-email (safe-ref fields 3 "")]
          [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")]
          [body (commit-message repo rev)] [file-count (changed-file-count repo rev file)]
+         [binary-count (binary-file-count repo rev file)]
          [files (changed-files repo rev file)] [paths (numstat-paths files)]
          [adds (numstat-adds files)] [dels (numstat-dels files)]
          [line-count (added-line-count-for-paths repo rev paths file)]
@@ -508,7 +549,7 @@
                   [heuristics-only? (verdict score '() "")]
                   [else (verdict score metadata note)])])
     (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 file-count line-count note-count))
+                        (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents binary-count))
           (cadr sim-pair))))
 
 (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?)
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 86fc9d7..9ea6e20 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -77,6 +77,9 @@ test "$jsonl_lines" = 2
 head_rev=$(git -C "$fixture" rev-parse HEAD)
 base_rev=$(git -C "$fixture" rev-parse HEAD~1)
 
+root_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --commit "$base_rev")
+printf '%s\n' "$root_json" | grep -q 'root commit; no parent diff baseline'
+
 explain=$("$root/bin/jerboa-aigit" explain "$head_rev" "$fixture")
 printf '%s\n' "$explain" | grep -q "commit: $head_rev"
 printf '%s\n' "$explain" | grep -q 'signals:'
@@ -158,6 +161,42 @@ printf '%s\n' "$insufficient" | grep -q '"verdict":"insufficient-evidence"'
 printf '%s\n' "$insufficient" | grep -q '"signals":\[\]'
 printf '%s\n' "$insufficient" | grep -q 'no changed text files found or commit is unavailable'
 
+base_branch=$(git -C "$fixture" branch --show-current)
+git -C "$fixture" checkout -q -b feature-branch
+mkdir -p "$fixture/branch"
+printf 'branch work\n' > "$fixture/branch/feature.txt"
+git -C "$fixture" add branch/feature.txt
+GIT_AUTHOR_DATE='2026-07-29T09:03:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:03:00-06:00' \
+  git -C "$fixture" commit -q -m 'feature branch work'
+git -C "$fixture" checkout -q "$base_branch"
+printf 'main work\n' > "$fixture/src/human.txt"
+git -C "$fixture" add src/human.txt
+GIT_AUTHOR_DATE='2026-07-29T09:04:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:04:00-06:00' \
+  git -C "$fixture" commit -q -m 'main branch work'
+GIT_AUTHOR_DATE='2026-07-29T09:05:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:05:00-06:00' \
+  git -C "$fixture" merge -q --no-ff feature-branch -m 'merge feature branch'
+merge_rev=$(git -C "$fixture" rev-parse HEAD)
+merge_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --commit "$merge_rev")
+printf '%s\n' "$merge_json" | grep -q 'merge commit; scanner uses first-parent diff'
+
+printf '*.bin binary\n' > "$fixture/.gitattributes"
+git -C "$fixture" add .gitattributes
+GIT_AUTHOR_DATE='2026-07-29T09:06:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:06:00-06:00' \
+  git -C "$fixture" commit -q -m 'mark binary assets'
+printf '\000\001binary\002\n' > "$fixture/model.bin"
+git -C "$fixture" add model.bin
+GIT_AUTHOR_DATE='2026-07-29T09:07:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:07:00-06:00' \
+  git -C "$fixture" commit -q -m 'add binary model asset'
+binary_rev=$(git -C "$fixture" rev-parse HEAD)
+binary_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --commit "$binary_rev")
+printf '%s\n' "$binary_json" | grep -q 'binary file changes skipped: 1'
+printf '%s\n' "$binary_json" | grep -q 'no added UTF-8 patch lines available'
+
 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