Add bounded scan resource limits

ober

8bced850074410550d091fb60aeb4665d0e814f9

diff --git a/README.md b/README.md
index a36dc86..bff0e3b 100644
--- a/README.md
+++ b/README.md
@@ -32,20 +32,27 @@ jerboa main-binary.ss scan /path/to/repo --format json
 
 Supported options are `--config FILE`, `--count N`, `--all`, `--from REV`,
 `--to REV`, `--commit REV`, `--file PATH`, `--include PATH`,
-`--exclude PREFIX`, `--min-lines N`, `--format table|json|jsonl|markdown`,
-`--metadata-only`, and `--heuristics-only`.
+`--exclude PREFIX`, `--min-lines N`, `--max-files N`,
+`--max-added-lines N`, `--max-note-bytes N`,
+`--format table|json|jsonl|markdown`, `--metadata-only`, and
+`--heuristics-only`.
 
 `--config FILE` reads guarded JSON. Supported scan keys are `path`, `count`,
 `format`, `from`, `to`, `file`, `exclude`, `min_lines`, `metadata_only`, and
 `heuristics_only`. Supported scoring keys are `human_threshold`,
 `ai_threshold`, `weight_text`, `weight_code`, `weight_structure`,
-`weight_similarity`, `weight_history`, and `weight_baseline`. Later CLI flags
-override earlier config values.
+`weight_similarity`, `weight_history`, `weight_baseline`, `max_files`,
+`max_added_lines`, and `max_note_bytes`. Later CLI flags override earlier
+config values.
 
 By default, scans skip `vendor/`, `generated/`, `dist/`, `node_modules/`, and
 `.git/` paths. Use `--include PATH` or `--file PATH` to inspect one of those
 paths explicitly.
 
+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.
+
 ## 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 f901f62..0804607 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -7,7 +7,7 @@
 
 (defstruct options (command path count format commit from to file min-lines metadata-only? heuristics-only?))
 (defstruct scan-config
-  (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight))
+  (hash human-threshold ai-threshold text-weight code-weight structure-weight similarity-weight history-weight baseline-weight max-files max-added-lines max-note-bytes))
 (defstruct signal (name category score weight confidence reason evidence limitations))
 (defstruct finding
   (commit parent author-name author-email time subject files additions deletions
@@ -23,7 +23,7 @@
         xs)))
 
 (def (usage)
-  (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--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|--include PATH] [--exclude PREFIX] [--min-lines N] [--max-files N] [--max-added-lines N] [--max-note-bytes 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]"))
@@ -83,9 +83,24 @@
   (for/or ([prefix current-excludes]) (public-prefix? prefix path)))
 
 (def (selected-file-records records explicit-file)
-  (if explicit-file
-      records
-      (filter (lambda (record) (not (excluded-path? (car record)))) records)))
+  (let ([selected (if explicit-file
+                      records
+                      (filter (lambda (record) (not (excluded-path? (car record)))) records))])
+    (bounded-list selected (scan-config-max-files current-config))))
+(def (bounded-list xs limit)
+  (if (and (> limit 0) (> (length xs) limit))
+      (take xs limit)
+      xs))
+
+(def (bounded-string s limit)
+  (if (and (> limit 0) (> (string-length s) limit))
+      (substring s 0 limit)
+      s))
+
+(def (limit-warning label actual limit)
+  (if (and (> limit 0) (> actual limit))
+      (list (str label " truncated from " actual " to " limit))
+      '()))
 (def (pathspec-args file)
   (if file (list "--" file) '()))
 
@@ -122,6 +137,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 (bounded-note-text repo rev)
+  (bounded-string (note-text repo rev) (scan-config-max-note-bytes current-config)))
+
+(def (note-byte-count repo rev)
+  (string-length (note-text repo rev)))
 (def (parse-json-object text)
   (let ([parsed (try-result (with-input-from-string text read-json))])
     (if (ok? parsed) (unwrap parsed) #f)))
@@ -175,6 +195,14 @@
                        (filter (lambda (line) (not (blank? line)))
                                (split-lines (git repo args))))])
     (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)))
 
 (def (numstat-adds files) (sum (map cadr files)))
 (def (numstat-dels files) (sum (map caddr files)))
@@ -191,6 +219,12 @@
   (if explicit-file
       (added-lines repo rev explicit-file)
       (append-map (lambda (path) (added-lines repo rev path)) paths)))
+(def (bounded-added-lines-for-paths repo rev paths explicit-file)
+  (bounded-list (added-lines-for-paths repo rev paths explicit-file)
+                (scan-config-max-added-lines current-config)))
+
+(def (added-line-count-for-paths repo rev paths explicit-file)
+  (length (added-lines-for-paths repo rev paths explicit-file)))
 
 (def known-agents
   '("codex" "copilot" "claude" "cursor" "openai" "anthropic" "aider" "windsurf" "cody" "tabnine" "ai-agent"))
@@ -320,7 +354,7 @@
   (str (stable-hash32 s)))
 
 (def (default-scan-config)
-  (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00))
+  (make-scan-config "default" 0.20 0.50 0.10 0.35 0.15 0.15 0.15 0.00 500 20000 50000))
 
 (def (config-fingerprint cfg)
   (hash-string (string-join
@@ -331,7 +365,10 @@
                       (str (scan-config-structure-weight cfg))
                       (str (scan-config-similarity-weight cfg))
                       (str (scan-config-history-weight cfg))
-                      (str (scan-config-baseline-weight cfg)))
+                      (str (scan-config-baseline-weight cfg))
+                      (str (scan-config-max-files cfg))
+                      (str (scan-config-max-added-lines cfg))
+                      (str (scan-config-max-note-bytes cfg)))
                 ":")))
 
 (def (finalize-config cfg)
@@ -343,7 +380,10 @@
                     (scan-config-structure-weight cfg)
                     (scan-config-similarity-weight cfg)
                     (scan-config-history-weight cfg)
-                    (scan-config-baseline-weight cfg)))
+                    (scan-config-baseline-weight cfg)
+                    (scan-config-max-files cfg)
+                    (scan-config-max-added-lines cfg)
+                    (scan-config-max-note-bytes cfg)))
 
 (def current-config (finalize-config (default-scan-config)))
 
@@ -428,9 +468,12 @@
                [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)
+(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") '())
           (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))
+          (limit-warning "AI note bytes" note-count (scan-config-max-note-bytes current-config))
           (if (and (> min-lines 0) (< (length lines) min-lines))
               (list (str "heuristics skipped below --min-lines " min-lines))
               '())
@@ -443,9 +486,12 @@
          [id (safe-ref fields 0 rev)] [parents (safe-ref fields 1 "")] [parent (first-parent parents)]
          [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)] [files (changed-files repo rev file)] [paths (numstat-paths files)]
-         [adds (numstat-adds files)] [dels (numstat-dels files)] [lines (added-lines-for-paths repo rev paths file)]
-         [note (note-text repo rev)] [note-obj (parse-note-object note)]
+         [body (commit-message repo rev)] [file-count (changed-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)]
+         [lines (bounded-added-lines-for-paths repo rev paths file)]
+         [note-count (note-byte-count repo rev)] [note (bounded-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))]
@@ -459,7 +505,7 @@
          [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 attribution metadata signals score v
-                        (warnings files lines note note-obj metadata-only? heuristics-only? min-lines))
+                        (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count))
           (cadr sim-pair))))
 
 (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?)
@@ -588,7 +634,26 @@
                      (config-number obj "weight_structure" (scan-config-structure-weight current-config))
                      (config-number obj "weight_similarity" (scan-config-similarity-weight current-config))
                      (config-number obj "weight_history" (scan-config-history-weight current-config))
-                     (config-number obj "weight_baseline" (scan-config-baseline-weight current-config)))))
+                     (config-number obj "weight_baseline" (scan-config-baseline-weight current-config))
+                     (config-number obj "max_files" (scan-config-max-files current-config))
+                     (config-number obj "max_added_lines" (scan-config-max-added-lines current-config))
+                     (config-number obj "max_note_bytes" (scan-config-max-note-bytes current-config)))))
+(def (update-resource-limit field value)
+  (let ([n (parse-int value 0)])
+    (set! current-config
+          (finalize-config
+           (make-scan-config "pending"
+                             (scan-config-human-threshold current-config)
+                             (scan-config-ai-threshold current-config)
+                             (scan-config-text-weight current-config)
+                             (scan-config-code-weight current-config)
+                             (scan-config-structure-weight current-config)
+                             (scan-config-similarity-weight current-config)
+                             (scan-config-history-weight current-config)
+                             (scan-config-baseline-weight current-config)
+                             (if (same-public-string? field "max-files") n (scan-config-max-files current-config))
+                             (if (same-public-string? field "max-added-lines") n (scan-config-max-added-lines current-config))
+                             (if (same-public-string? field "max-note-bytes") n (scan-config-max-note-bytes current-config)))))))
 
 (def (apply-config opts path)
   (let ([obj (read-config-object path)])
@@ -666,6 +731,18 @@
            (begin
              (set! current-excludes (cons (cadr xs) current-excludes))
              (loop (cddr xs) opts path-set?))]
+          [(and (string=? (car xs) "--max-files") (pair? (cdr xs)))
+           (begin
+             (update-resource-limit "max-files" (cadr xs))
+             (loop (cddr xs) opts path-set?))]
+          [(and (string=? (car xs) "--max-added-lines") (pair? (cdr xs)))
+           (begin
+             (update-resource-limit "max-added-lines" (cadr xs))
+             (loop (cddr xs) opts path-set?))]
+          [(and (string=? (car xs) "--max-note-bytes") (pair? (cdr xs)))
+           (begin
+             (update-resource-limit "max-note-bytes" (cadr xs))
+             (loop (cddr xs) opts path-set?))]
           [(and (string=? (car xs) "--min-lines") (pair? (cdr xs)))
            (loop (cddr xs)
                  (make-options (options-command opts) (options-path opts) (options-count opts) (options-format opts)
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index ceb02db..1c6d887 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -96,6 +96,17 @@ exclude_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 
 printf '%s\n' "$exclude_json" | grep -q '"files":\[\]'
 printf '%s\n' "$exclude_json" | grep -q 'no changed text files found or commit is unavailable'
 
+max_file_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --include . --max-files 1)
+printf '%s\n' "$max_file_json" | grep -q 'files truncated from 2 to 1'
+
+max_line_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --max-added-lines 3)
+printf '%s\n' "$max_line_json" | grep -q '"added_lines":3'
+printf '%s\n' "$max_line_json" | grep -q 'added lines truncated from 450 to 3'
+
+max_note_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --max-note-bytes 10)
+printf '%s\n' "$max_note_json" | grep -q 'AI note bytes truncated from '
+printf '%s\n' "$max_note_json" | grep -q 'refs/notes/ai note is not supported JSON'
+
 min_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --min-lines 999)
 printf '%s\n' "$min_json" | grep -q '"signals":\[\]'
 printf '%s\n' "$min_json" | grep -q 'heuristics skipped below --min-lines 999'
@@ -114,6 +125,11 @@ printf '{"format":"json","count":1,"exclude":["src/"]}\n' > "$config_file"
 config_exclude_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file")
 printf '%s\n' "$config_exclude_json" | grep -q '"files":\[\]'
 
+printf '{"format":"json","count":1,"max_added_lines":2}\n' > "$config_file"
+config_limit_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file")
+printf '%s\n' "$config_limit_json" | grep -q '"added_lines":2'
+printf '%s\n' "$config_limit_json" | grep -q 'added lines truncated from 450 to 2'
+
 printf '{"format":"json","count":1,"weight_code":0.0,"weight_text":0.0,"weight_structure":0.0,"weight_similarity":0.0,"weight_history":0.0,"ai_threshold":0.9,"human_threshold":0.1}\n' > "$config_file"
 weighted_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file" --heuristics-only)
 printf '%s\n' "$weighted_json" | grep -q '"config_hash":"[0-9]'