diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index bac5c834..92e4abac 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -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!)" ) diff --git a/lib/mod/lint.sx b/lib/mod/lint.sx new file mode 100644 index 00000000..b3e83597 --- /dev/null +++ b/lib/mod/lint.sx @@ -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)))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 031c5e68..d75cab7b 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -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" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index e10a739d..ecec4269 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -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 | diff --git a/lib/mod/tests/lint.sx b/lib/mod/tests/lint.sx new file mode 100644 index 00000000..2aa14177 --- /dev/null +++ b/lib/mod/tests/lint.sx @@ -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})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index cd5b4755..8670105d 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -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.