mod: Ext 5 — policy rule-set lint (unreachable/catch-all/dups), 190/190
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
Static analysis of a policy without running the engine: mod/unreachable-rules flags rules after an unconditional rule (dead under first-match precedence), mod/has-catchall? checks total coverage, mod/duplicate-rule-names + mod/rules-ok? give a well-formedness verdict policy authors can assert. Own suite. +14 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ PRELOADS=(
|
||||
lib/mod/api.sx
|
||||
lib/mod/fed.sx
|
||||
lib/mod/link.sx
|
||||
lib/mod/lint.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
@@ -28,4 +29,5 @@ SUITES=(
|
||||
"fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)"
|
||||
"extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)"
|
||||
"link:lib/mod/tests/link.sx:(mod-link-tests-run!)"
|
||||
"lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)"
|
||||
)
|
||||
|
||||
69
lib/mod/lint.sx
Normal file
69
lib/mod/lint.sx
Normal file
@@ -0,0 +1,69 @@
|
||||
;; 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))))
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"lang": "mod",
|
||||
"total_passed": 176,
|
||||
"total_passed": 190,
|
||||
"total_failed": 0,
|
||||
"total": 176,
|
||||
"total": 190,
|
||||
"suites": [
|
||||
{"name":"decide","passed":31,"failed":0,"total":31},
|
||||
{"name":"audit","passed":29,"failed":0,"total":29},
|
||||
{"name":"escalation","passed":46,"failed":0,"total":46},
|
||||
{"name":"fed","passed":26,"failed":0,"total":26},
|
||||
{"name":"extensions","passed":32,"failed":0,"total":32},
|
||||
{"name":"link","passed":12,"failed":0,"total":12}
|
||||
{"name":"link","passed":12,"failed":0,"total":12},
|
||||
{"name":"lint","passed":14,"failed":0,"total":14}
|
||||
],
|
||||
"generated": "2026-06-06T18:09:14+00:00"
|
||||
"generated": "2026-06-06T18:15:06+00:00"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# mod scoreboard
|
||||
|
||||
**176 / 176 passing** (0 failure(s)).
|
||||
**190 / 190 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
@@ -10,3 +10,4 @@
|
||||
| fed | 26 | 26 | ok |
|
||||
| extensions | 32 | 32 | ok |
|
||||
| link | 12 | 12 | ok |
|
||||
| lint | 14 | 14 | ok |
|
||||
|
||||
122
lib/mod/tests/lint.sx
Normal file
122
lib/mod/tests/lint.sx
Normal file
@@ -0,0 +1,122 @@
|
||||
;; lib/mod/tests/lint.sx — Ext 5: policy rule-set static analysis.
|
||||
|
||||
(define mod-lint-count 0)
|
||||
(define mod-lint-pass 0)
|
||||
(define mod-lint-fail 0)
|
||||
(define mod-lint-failures (list))
|
||||
|
||||
(define
|
||||
mod-lint-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-lint-count (+ mod-lint-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-lint-pass (+ mod-lint-pass 1))
|
||||
(begin
|
||||
(set! mod-lint-fail (+ mod-lint-fail 1))
|
||||
(append!
|
||||
mod-lint-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── the default rule set is well-formed ──
|
||||
|
||||
(mod-lint-test!
|
||||
"default rules: no unreachable"
|
||||
(mod/unreachable-rules mod/default-rules)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"default rules: has catch-all"
|
||||
(mod/has-catchall? mod/default-rules)
|
||||
true)
|
||||
(mod-lint-test!
|
||||
"default rules: no duplicate names"
|
||||
(mod/duplicate-rule-names mod/default-rules)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"default rules: well-formed"
|
||||
(mod/rules-ok? mod/default-rules)
|
||||
true)
|
||||
|
||||
;; ── unreachable detection ──
|
||||
|
||||
(define
|
||||
mod-lint-shadowed
|
||||
(list
|
||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule "catch-all" :keep (list))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule
|
||||
"repeated"
|
||||
:escalate (list (list :count-at-least 3)))))
|
||||
|
||||
(mod-lint-test!
|
||||
"rules after catch-all are unreachable"
|
||||
(mod/unreachable-rules mod-lint-shadowed)
|
||||
(list "abuse-remove" "repeated"))
|
||||
(mod-lint-test!
|
||||
"shadowed rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-shadowed)
|
||||
false)
|
||||
|
||||
;; ── missing catch-all ──
|
||||
|
||||
(define
|
||||
mod-lint-nocatch
|
||||
(list
|
||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))))
|
||||
|
||||
(mod-lint-test!
|
||||
"no catch-all detected"
|
||||
(mod/has-catchall? mod-lint-nocatch)
|
||||
false)
|
||||
(mod-lint-test!
|
||||
"no unreachable when no catch-all"
|
||||
(mod/unreachable-rules mod-lint-nocatch)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"no-catch-all rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-nocatch)
|
||||
false)
|
||||
|
||||
;; ── duplicate names ──
|
||||
|
||||
(define
|
||||
mod-lint-dups
|
||||
(list
|
||||
(mod/mk-rule "x" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule "x" :remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule "default" :keep (list))))
|
||||
|
||||
(mod-lint-test!
|
||||
"duplicate names detected"
|
||||
(mod/duplicate-rule-names mod-lint-dups)
|
||||
(list "x"))
|
||||
(mod-lint-test!
|
||||
"duplicate-name rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-dups)
|
||||
false)
|
||||
|
||||
;; ── helpers ──
|
||||
|
||||
(mod-lint-test!
|
||||
"rule-unconditional? true for empty when"
|
||||
(mod/rule-unconditional? (mod/mk-rule "d" :keep (list)))
|
||||
true)
|
||||
(mod-lint-test!
|
||||
"rule-unconditional? false with conditions"
|
||||
(mod/rule-unconditional?
|
||||
(mod/mk-rule "s" :hide (list (list :classification "spam"))))
|
||||
false)
|
||||
(mod-lint-test!
|
||||
"count-eq counts occurrences"
|
||||
(mod/count-eq "a" (list "a" "b" "a"))
|
||||
2)
|
||||
|
||||
(define mod-lint-tests-run! (fn () {:failures mod-lint-failures :total mod-lint-count :passed mod-lint-pass :failed mod-lint-fail}))
|
||||
@@ -16,7 +16,7 @@ federation extension.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/mod/conformance.sh` → **176/176** (roadmap + 4 extensions complete)
|
||||
`bash lib/mod/conformance.sh` → **190/190** (roadmap + 5 extensions complete)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -147,6 +147,11 @@ lib/mod/fed.sx
|
||||
derivation goal-by-goal with `[proved]`/`[unproved]` marks and unification
|
||||
bindings. E.g. `Report rc: escalate (rule: repeated-escalate)` … `[proved]
|
||||
report(rc, B, S), report_count(S, N), N >= 3 {B=ann, N=3, S=dave}`.
|
||||
- [x] **Ext 5 — policy lint** (`lib/mod/lint.sx`, +14). Static analysis of a rule
|
||||
set: `mod/unreachable-rules` flags rules placed after an unconditional (always-
|
||||
matching) rule — structurally dead under first-match precedence;
|
||||
`mod/has-catchall?` checks every report gets a decision; `mod/duplicate-rule-names`
|
||||
+ `mod/rules-ok?` give a one-call well-formedness verdict. No engine run needed.
|
||||
- [x] **Ext 4 — report linking / dedup** (`lib/mod/link.sx`, +12). `mod/related-ids`
|
||||
and `mod/reporters-of` find reports about a subject via a Prolog relational query
|
||||
(`report(Id, _, 'subject')`) — the policy substrate reused for retrieval.
|
||||
@@ -155,6 +160,10 @@ lib/mod/fed.sx
|
||||
|
||||
## Progress log
|
||||
|
||||
- **Ext 5 — policy lint, 190/190** (+14). Static analysis of the rule set itself,
|
||||
catching the failure modes first-match precedence makes easy: dead rules after a
|
||||
catch-all, missing catch-all (undecided reports), duplicate names. `mod/rules-ok?`
|
||||
is a single well-formedness gate a policy author can assert in their own tests.
|
||||
- **Ext 4 — report linking / dedup, 176/176** (+12). Relational retrieval
|
||||
(`related-ids`, `reporters-of`) reuses the Prolog substrate for *querying* report
|
||||
clusters, not just deciding them — `report(Id, _, 'subject')` by unification.
|
||||
|
||||
Reference in New Issue
Block a user