;; Title: Miniforge.ai
;; Subtitle: An agentic SDLC / fleet-control platform
;; Author: Christopher Lester
;; Line: Founder, Miniforge.ai (project)
;; Copyright 2025-2026 Christopher Lester (christopher@miniforge.ai)
;;
;; Licensed under the Apache License, Version 2.0 (the "License");
;; you may not use this file except in compliance with the License.
;; You may obtain a copy of the License at
;;
;;     http://www.apache.org/licenses/LICENSE-2.0
;;
;; Unless required by applicable law or agreed to in writing, software
;; distributed under the License is distributed on an "AS IS" BASIS,
;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
;; See the License for the specific language governing permissions and
;; limitations under the License.

(ns commit-budget
  "Hard ceiling on staged-diff size at commit time — the fix for agents
   producing 4k-line monoliths. Counts insertions+deletions across
   staged source files, blocks the commit when the total exceeds the
   budget.

   The line count is the *reviewable* line count, not raw `git
   numstat`:

   - File-level exclusions: lockfiles, auto-generated audit trails,
     resource EDN/JSON catalogs, the `.standards` submodule pointer.
     Counted at zero.
   - Line-level exclusions inside source files: blank lines and
     single-line `;`/`#`/`//` comments. The header copyright block
     and per-function commentary don't represent review effort and
     shouldn't push a small change over budget.

   Default: 200 lines. Override via env var
   `MINIFORGE_COMMIT_BUDGET_OVERRIDE=\"<rationale>\"` (the rationale is
   echoed to stdout so the override is auditable in CI logs)."
  (:require [babashka.process :as p]
            [clojure.string :as str]))

;; ----------------------------------------------------------------- Layer 0
;; pure helpers (no in-namespace deps)

(def default-budget
  "Hard ceiling on insertions+deletions per commit. 200 lines is the
   lower bound from review-throughput research (`Smartbear 2010`,
   replicated in `Cisco code review study 2018`): reviewer
   defect-detection drops sharply past ~200 LOC and stays low through
   400. Picking the lower bound forces small slices."
  200)

(def excluded-path-patterns
  "Regex patterns for files whose line-count should NOT count against
   the budget. Generated content, audit trails, lockfiles, submodule
   pointers, and bulk data resources don't benefit from human review."
  [#"(?i)^.*lock(?:file)?(?:\.json)?$"          ; package-lock, deps.lock
   #"(?i)^.*\.sum$"                              ; go.sum etc.
   #"(?i)^docs/pull-requests/.*\.md$"            ; auto-generated by compliance scanner
   #"(?i)^docs/standards/.*-baseline\.md$"       ; audit baseline reports
   #"(?i)(^|/)CHANGELOG\.md$"                    ; auto-rolled changelogs
   #"(?i)(^|/)\.standards$"                      ; submodule pointer
   #"(?i)(^|/)work/(done|archive)/.*"            ; archived work specs
   #"(?i)\.miniforge/.*"                         ; runtime cache
   #"(?i)\.claude/worktrees/.*"                  ; nested worktree dirs
   ;; Data-file extensions in resources/, messages/, fixtures/, etc.
   ;; — bulk data, not reviewed line-by-line. Code-shaped EDN
   ;; (deps.edn, bb.edn, workspace.edn, *.spec.edn) is NOT data and
   ;; stays in the budget. The `(^|/)` anchor matches both nested
   ;; (`components/X/resources/…`) and top-level (`resources/…`)
   ;; layouts — without it, root-level resource files would silently
   ;; count against the budget.
   ;;
   ;; A bare `\.(json|yaml|yml|csv|tsv)$` catch-all was tempting but
   ;; over-broad: it would also exclude .github workflow YAMLs,
   ;; tsconfig.json, and other behavior-as-data files reviewers DO
   ;; need to look at. Stick with the resource/message/fixture/seed
   ;; gate.
   #"(?i)(^|/)(resources|messages|fixtures|fixture|seeds)/.*\.(edn|json|yaml|yml|csv|tsv)$"])

(defn excluded?
  "True when `path` matches any pattern in `excluded-path-patterns`."
  [path]
  (boolean (some #(re-find % path) excluded-path-patterns)))

(def code-extension->comment-prefix
  "Single-line comment marker per source-file extension. Lines that
   start (after whitespace) with the prefix are excluded from the
   budget — the file-header copyright block and per-function
   commentary don't represent review effort."
  {".clj"  ";"   ".cljc" ";"  ".cljs" ";"  ".bb"  ";"
   ".edn"  ";"   ".py"   "#"  ".sh"   "#"  ".bash" "#"
   ".yml"  "#"   ".yaml" "#"  ".toml" "#"
   ".js"   "//"  ".ts"   "//" ".tsx"  "//" ".jsx" "//"
   ".rs"   "//"  ".go"   "//" ".java" "//"})

(defn path-extension
  "Lowercase file extension (with dot) for `path`, or nil."
  [^String path]
  (let [i (.lastIndexOf path ".")]
    (when (pos? i) (str/lower-case (subs path i)))))

(defn comment-or-blank?
  "True when `line` is blank OR a single-line comment in the syntax of
   the file at `path`. Used to drop comment/blank lines from the
   reportable count."
  [line path]
  (let [trimmed (str/triml line)]
    (or (str/blank? trimmed)
        (when-let [prefix (code-extension->comment-prefix (path-extension path))]
          (str/starts-with? trimmed prefix)))))

(defn classify-line
  "Tag a diff payload line as `:added` / `:deleted` / `:other`.

   The unified diff prefixes added lines with `+`, deleted with `-`.
   We ignore hunk headers (`+++ b/…`, `--- a/…`) — those carry the
   path, not content."
  [line]
  (cond
    (or (str/starts-with? line "+++") (str/starts-with? line "---")) :other
    (str/starts-with? line "+") :added
    (str/starts-with? line "-") :deleted
    :else :other))

(defn parse-diff
  "Parse `git diff --cached -U0 --no-color` into a vector of per-file
   maps `{:path :added [\"line\" …] :deleted [\"line\" …]}`.

   Whole-file deletions emit `--- a/<path>` followed by `+++
   /dev/null`; track the `--- a/` path separately and use it when
   the `+++` side is /dev/null so the deletion's `-` lines are
   counted against the budget rather than dropped."
  [diff-text]
  (loop [lines       (str/split-lines (or diff-text ""))
         pending-old nil
         current     nil
         out         []]
    (if (empty? lines)
      (cond-> out current (conj current))
      (let [line (first lines)]
        (cond
          (str/starts-with? line "--- a/")
          (recur (rest lines) (subs line (count "--- a/")) current out)

          (str/starts-with? line "--- /dev/null")
          (recur (rest lines) nil current out)

          (str/starts-with? line "+++ b/")
          (recur (rest lines) nil
                 {:path (subs line (count "+++ b/")) :added [] :deleted []}
                 (cond-> out current (conj current)))

          (str/starts-with? line "+++ /dev/null")
          ;; whole-file deletion: open a new entry under the
          ;; previously-seen `--- a/<path>` and accumulate the `-`
          ;; lines into it.
          (recur (rest lines) nil
                 (when pending-old {:path pending-old :added [] :deleted []})
                 (cond-> out current (conj current)))

          (and current (= :added (classify-line line)))
          (recur (rest lines) pending-old (update current :added conj (subs line 1)) out)

          (and current (= :deleted (classify-line line)))
          (recur (rest lines) pending-old (update current :deleted conj (subs line 1)) out)

          :else
          (recur (rest lines) pending-old current out))))))

(defn file-reportable-count
  "Count budget-impacting lines in one file entry. File-level
   exclusion → 0. Otherwise drop blank/comment lines and sum
   added+deleted."
  [{:keys [path added deleted]}]
  (if (excluded? path)
    0
    (let [code? #(not (comment-or-blank? % path))]
      (+ (count (filter code? added))
         (count (filter code? deleted))))))

(defn reportable-changes
  "Per-file annotated counts: `{:path :reportable :raw}`. `:raw` is the
   full insertions+deletions; `:reportable` is what counts toward
   budget after blank/comment filtering and path exclusions."
  [entries]
  (mapv (fn [{:keys [path added deleted] :as e}]
          {:path       path
           :reportable (file-reportable-count e)
           :raw        (+ (count added) (count deleted))})
        entries))

(defn total-lines
  "Sum reportable counts across `annotated-entries`."
  [annotated-entries]
  (reduce + 0 (map :reportable annotated-entries)))

;; ----------------------------------------------------------------- Layer 1
;; git wrapper (composes Layer 0)

(defn staged-diff
  "Return the parsed `git diff --cached -U0` for the current index, as
   per-file diff maps. Empty vec when nothing is staged.

   Fails the task (`System/exit 1`) on a non-zero git exit so the
   gate fails CLOSED on a malformed repo state — the only safe
   direction. The stderr from git is printed first so the operator
   can see why."
  []
  (let [{:keys [out err exit]}
        (p/sh {:out :string :err :string}
              "git" "diff" "--cached" "-U0" "--no-color")]
    (cond
      (zero? exit) (parse-diff out)
      :else
      (do (binding [*out* *err*]
            (println (format "❌ commit-budget: `git diff --cached -U0` exited %d" exit))
            (when-not (str/blank? err)
              (println (str/trim err))))
          (System/exit 1)))))

(defn override-rationale
  "Read the override rationale from the env var, or nil when none set."
  []
  (some-> (System/getenv "MINIFORGE_COMMIT_BUDGET_OVERRIDE")
          str/trim
          not-empty))

;; ----------------------------------------------------------------- Layer 2
;; report (composes Layer 1)

(defn print-report!
  "Emit a human-readable summary of which files contributed to the
   total + which were excluded. The `(reportable / raw)` column makes
   it visible when blank/comment filtering pulled a file under
   budget."
  [annotated total]
  (let [{counted true zero false} (group-by #(pos? (:reportable %)) annotated)]
    (println (format "  Reportable lines: %d (after blank/comment + path filters)" total))
    (when (seq counted)
      (println "  Files counted (reportable / raw):")
      (doseq [{:keys [path reportable raw]} (sort-by (comp - :reportable) counted)]
        (println (format "    %5d / %-5d  %s" reportable raw path))))
    (when (seq zero)
      (println "  Files at zero reportable (excluded path or comments-only):")
      (doseq [{:keys [path raw]} (sort-by (comp - :raw) zero)]
        (println (format "          %-5d  %s" raw path))))))

;; ----------------------------------------------------------------- Layer 3
;; CLI entry (composes Layer 2)

(defn check-commit-budget!
  "Pre-commit gate. Returns nil when within budget; calls
   `(System/exit 1)` only on hard failure so it composes inside the
   `bb pre-commit` `:depends` chain (a `(System/exit 0)` here would
   terminate the JVM before lint/fmt/test got to run).

   - With no staged content, returns silently (lets git itself emit
     the 'nothing to commit' message).
   - With `MINIFORGE_COMMIT_BUDGET_OVERRIDE` set, succeeds and echoes
     the rationale.
   - Otherwise prints a per-file breakdown and the verdict."
  [budget]
  (let [entries    (staged-diff)
        annotated  (reportable-changes entries)
        total      (total-lines annotated)
        rationale  (override-rationale)]
    (cond
      (empty? entries)
      nil   ; truly silent — let git handle the 'nothing to commit' messaging

      rationale
      (do (println (format "📏 commit-budget: OVERRIDDEN (%d reportable lines)" total))
          (println (str "  rationale: " rationale))
          (when (> total budget)
            (println (format "  WARNING: %d > %d — budget intentionally bypassed."
                             total budget)))
          (print-report! annotated total))

      (<= total budget)
      (println (format "📏 commit-budget: %d / %d lines OK" total budget))

      :else
      (do (println "")
          (println "════════════════════════════════════════════════════════════════")
          (println (format "❌ COMMIT BUDGET EXCEEDED: %d > %d lines" total budget))
          (println "════════════════════════════════════════════════════════════════")
          (println "")
          (print-report! annotated total)
          (println "")
          (println "Why this exists:")
          (println "  Reviewer defect-detection drops sharply past ~200 LOC.")
          (println "  Slicing a large change into a stack of small commits keeps")
          (println "  each one reviewable; the agent that merges first is still on")
          (println "  the way to the same destination.")
          (println "")
          (println "How to proceed:")
          (println "  • Slice the work: stage only the files for the first PR,")
          (println "    commit, then continue with the next slice.")
          (println "  • Genuine exception (lockfile rev, generated migration):")
          (println "    MINIFORGE_COMMIT_BUDGET_OVERRIDE='<rationale>' git commit ...")
          (println "    The rationale is echoed at commit time and in CI logs.")
          (println "    (It is not written into the commit message itself —")
          (println "     repeat it in your -m '...' if you want it in the trail.)")
          (println "")
          (System/exit 1)))))

(defn run [_opts]
  (check-commit-budget! default-budget))
