;; lib/mod/lint.sx — static analysis of a policy rule set.
;;
;; Because precedence is "first matching clause wins" (pl-query-one), the rule
;; order has correctness consequences a moderator can get wrong: a rule placed
;; after an unconditional (empty :when) rule can never fire, and a rule set with
;; no unconditional rule may leave some reports undecided. lint-rules surfaces
;; these without running the engine.

(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r))))

;; names of rules that follow the first unconditional rule — structurally dead,
;; since the unconditional rule always matches first
(define
  mod/unreachable-rules
  (fn
    (rules)
    (get
      (reduce
        (fn
          (acc r)
          (if
            (get acc :hit)
            {:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true}
            (if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc)))
        {:dead (list) :hit false}
        rules)
      :dead)))

(define
  mod/has-catchall?
  (fn (rules) (mod/any? mod/rule-unconditional? rules)))

(define
  mod/count-eq
  (fn
    (x lst)
    (reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst)))

(define
  mod/duplicate-rule-names
  (fn
    (rules)
    (let
      ((names (map mod/rule-name rules)))
      (mod/distinct
        (reduce
          (fn
            (acc n)
            (if
              (< 1 (mod/count-eq n names))
              (append acc (list n))
              acc))
          (list)
          names)))))

(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)}))

;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule
;; names are unique
(define
  mod/rules-ok?
  (fn
    (rules)
    (let
      ((l (mod/lint-rules rules)))
      (if
        (empty? (get l :unreachable))
        (if (get l :has-catchall) (empty? (get l :duplicate-names)) false)
        false))))
