Add guarded JSON config support

ober

757e01dcfce8f124daa82b8ff471054919aa3078

diff --git a/README.md b/README.md
index 7cbd85a..1252e51 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ repository data over the network.
 ```bash
 ./bin/jerboa-aigit scan /path/to/repo --count 25
 ./bin/jerboa-aigit scan /path/to/repo --format json
+./bin/jerboa-aigit scan /path/to/repo --config aigit.json
 ./bin/jerboa-aigit scan /path/to/repo --from main~20 --to HEAD --file src/app.ss
 ./bin/jerboa-aigit explain HEAD /path/to/repo --format markdown
 ./bin/jerboa-aigit stats /path/to/repo --count 100
@@ -29,11 +30,15 @@ During development, the same entrypoint can be run directly:
 jerboa main-binary.ss scan /path/to/repo --format json
 ```
 
-Supported options are `--count N`, `--all`, `--from REV`, `--to REV`,
-`--commit REV`, `--file PATH`, `--min-lines N`,
+Supported options are `--config FILE`, `--count N`, `--all`, `--from REV`,
+`--to REV`, `--commit REV`, `--file PATH`, `--min-lines N`,
 `--format table|json|jsonl|markdown`, `--metadata-only`, and
 `--heuristics-only`.
 
+`--config FILE` reads guarded JSON. Supported keys are `path`, `count`,
+`format`, `from`, `to`, `file`, `min_lines`, `metadata_only`, and
+`heuristics_only`. Later CLI flags override earlier config values.
+
 ## What It Reads
 
 The scanner runs Git commands against the requested repository using fixed
diff --git a/main-binary.ss b/main-binary.ss
index 5d94fd0..b7824da 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -21,7 +21,7 @@
         xs)))
 
 (def (usage)
-  (displayln "usage: jerboa main-binary.ss scan [PATH] [--count N|--all] [--from REV] [--to REV] [--file PATH] [--min-lines N] [--format table|json|jsonl|markdown]")
+  (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH] [--min-lines N] [--format table|json|jsonl|markdown]")
   (displayln "       jerboa main-binary.ss explain REV [PATH] [--format json|markdown|table]")
   (displayln "       jerboa main-binary.ss stats [PATH] [--count N]")
   (displayln "       jerboa main-binary.ss verify-authorship [PATH] [--count N]"))
@@ -62,6 +62,8 @@
 (def (count-where pred xs) (for/fold ([n 0]) ([x xs]) (if (pred x) (+ n 1) n)))
 (def (any-contains? text needles) (for/or ([needle needles]) (contains? text needle)))
 (def (starts-with-any? text prefixes) (for/or ([prefix prefixes]) (string-prefix? prefix text)))
+(def (same-public-string? a b)
+  (and (not (string<? a b)) (not (string<? b a))))
 (def (maybe-append xs ys)
   (if (null? ys) xs (append xs ys)))
 
@@ -101,11 +103,11 @@
 
 (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-json-object text)
+  (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
-      (let ([parsed (try-result (with-input-from-string note read-json))])
-        (if (ok? parsed) (unwrap parsed) #f))))
+  (if (string-empty? note) #f (parse-json-object note)))
 
 (def (hash-get/default obj key fallback)
   (if (hash-table? obj)
@@ -340,23 +342,23 @@
         [(>= score 0.20) "mixed-uncertain"]
         [else "likely-human-style"]))
 
-(def (prior-additions repo revs email current file)
+(def (prior-additions repo revs author-id current file)
   (let loop ([xs revs] [out '()])
     (if (null? xs)
         out
         (let* ([rev (car xs)] [fields (commit-fields repo rev)] [e (safe-ref fields 3 "")])
-          (if (and (string=? e email) (not (string=? rev current)))
+          (if (and (same-public-string? e author-id) (not (same-public-string? rev current)))
               (loop (cdr xs) (cons (numstat-adds (changed-files repo rev file)) out))
               (loop (cdr xs) out))))))
 
-(def (author-times repo revs email)
+(def (author-times repo revs author-id)
   (let loop ([xs revs] [out '()])
     (if (null? xs)
         out
         (let* ([fields (commit-fields repo (car xs))]
                [e (safe-ref fields 3 "")]
                [t (parse-int (safe-ref fields 4 "0") 0)])
-          (if (string=? e email) (loop (cdr xs) (cons t out)) (loop (cdr xs) out))))))
+          (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)
   (append (if (null? files) '("no changed text files found or commit is unavailable") '())
@@ -482,9 +484,43 @@
   (displayln (str "commits: " (length findings)))
   (displayln (str "recorded AI notes: " (count-where (lambda (f) (not (string-empty? (finding-note f)))) findings)))
   (displayln (str "metadata indicated agents: " (count-where (lambda (f) (pair? (finding-metadata f))) findings)))
-  (displayln (str "likely AI-assisted by heuristics: " (count-where (lambda (f) (string=? (finding-verdict f) "likely-ai-assisted")) findings))))
+  (displayln (str "likely AI-assisted by heuristics: " (count-where (lambda (f) (same-public-string? (finding-verdict f) "likely-ai-assisted")) findings))))
 
 (def (default-options) (make-options "scan" "." 50 "table" #f #f #f #f 0 #f #f))
+(def (config-value obj field fallback)
+  (if (hash-table? obj) (hash-ref obj field fallback) fallback))
+
+(def (config-string obj field fallback)
+  (let ([v (config-value obj field fallback)])
+    (if (string? v) v fallback)))
+
+(def (config-number obj field fallback)
+  (let ([v (config-value obj field fallback)])
+    (if (number? v) v fallback)))
+
+(def (config-bool obj field fallback)
+  (let ([v (config-value obj field fallback)])
+    (if (boolean? v) v fallback)))
+
+(def (read-config-object path)
+  (let ([content (try-result (read-file-string path))])
+    (if (ok? content) (parse-json-object (unwrap content)) #f)))
+
+(def (apply-config opts path)
+  (let ([obj (read-config-object path)])
+    (if (hash-table? obj)
+        (make-options (options-command opts)
+                      (config-string obj "path" (options-path opts))
+                      (config-number obj "count" (options-count opts))
+                      (config-string obj "format" (options-format opts))
+                      (options-commit opts)
+                      (config-string obj "from" (options-from opts))
+                      (config-string obj "to" (options-to opts))
+                      (config-string obj "file" (options-file opts))
+                      (config-number obj "min_lines" (options-min-lines opts))
+                      (config-bool obj "metadata_only" (options-metadata-only? opts))
+                      (config-bool obj "heuristics_only" (options-heuristics-only? opts)))
+        opts)))
 (def (parse-options args)
   (let loop ([xs args] [opts (default-options)] [path-set? #f])
     (cond [(null? xs) opts]
@@ -494,6 +530,8 @@
                                (options-commit opts) (options-from opts) (options-to opts) (options-file opts)
                                (options-min-lines opts) (options-metadata-only? opts) (options-heuristics-only? opts))
                  path-set?)]
+          [(and (string=? (car xs) "--config") (pair? (cdr xs)))
+           (loop (cddr xs) (apply-config opts (cadr xs)) path-set?)]
           [(string=? (car xs) "--all")
            (loop (cdr xs)
                  (make-options (options-command opts) (options-path opts) 0 (options-format opts)
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index f873f57..eef23d9 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -88,6 +88,16 @@ 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"
 
+config_file="$fixture/aigit.json"
+printf '{"format":"json","count":5,"file":"src/generated.py","min_lines":1}\n' > "$config_file"
+config_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file")
+printf '%s\n' "$config_json" | grep -q '"count":1'
+printf '%s\n' "$config_json" | grep -q '"files":\["src/generated.py"\]'
+
+printf 'not json\n' > "$config_file"
+bad_config_table=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file" --count 1)
+printf '%s\n' "$bad_config_table" | grep -q 'commit        score  verdict'
+
 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)