Add guarded JSON config support
ober
757e01dcfce8f124daa82b8ff471054919aa3078
--- 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 --- 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) --- 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)