///|
/// Static rule-set analysis.
///
/// The analyze operation compiles every rule and reports, without any
/// facts: the fact paths each rule depends on (condition plus compute),
/// condition complexity metrics, canonical condition forms, and a list
/// of findings — duplicate conditions that shadow each other under the
/// priority order, constant-true conditions, and equal-priority ties on
/// identical conditions.
///|
fn collect_paths(expr : Expression, paths : Map[String, Bool]) -> Unit {
match expr {
Literal(_) => ()
Variable(path) => paths[path] = true
Unary(_, operand) => collect_paths(operand, paths)
Binary(_, left, right) => {
collect_paths(left, paths)
collect_paths(right, paths)
}
Call(_, args) =>
for arg in args {
collect_paths(arg, paths)
}
}
}
///|
fn sorted_paths(paths : Map[String, Bool]) -> Array[String] {
let names : Array[String] = []
for path, _ in paths {
names.push(path)
}
names.sort_by(fn(a, b) { stable_string_compare(a, b) })
names
}
///|
fn analyze_rules(request : Value) -> Value raise {
let rules = arr(get(request, "rules"))
let report : Array[Value] = []
let findings : Array[Value] = []
let conditions : Map[String, Array[Value]] = Map([])
let mut index = 0
for item in rules {
let id = match get(item, "id") {
String(name) => name
_ =>
raise input_error(
"E4006",
"rule at index " + index.to_string() + " needs an ID",
)
}
let condition = compile(str(get(item, "when")))
let paths : Map[String, Bool] = Map([])
collect_paths(condition, paths)
match get(item, "compute") {
Null => ()
String(source) => collect_paths(compile(source), paths)
_ => raise input_error("E4007", "compute must be a string")
}
let dependency_list = sorted_paths(paths)
let dependencies = strings(dependency_list)
let (nodes, depth) = condition.metrics()
let canonical = condition.render()
let priority = optional_num(get(item, "priority"), 0.0)
let flags : Array[Value] = []
if canonical == "true" {
flags.push(String("tautology"))
findings.push(
record([
("rule", String(id)),
("kind", String("tautology")),
("detail", String("condition is constant true")),
]),
)
}
match conditions.get(canonical) {
Some(existing) => {
let mut other_id = ""
let mut other_priority = 0.0
match existing[0] {
Object(fields) => {
other_id = match fields.get("id") {
Some(String(name)) => name
_ => ""
}
other_priority = match fields.get("priority") {
Some(Number(value)) => value
_ => 0.0
}
}
_ => ()
}
let relation = if priority > other_priority {
"shadows " + other_id
} else if priority < other_priority {
"shadowed by " + other_id
} else {
"ties with " + other_id + " at equal priority"
}
flags.push(String("duplicate_condition"))
findings.push(
record([
("rule", String(id)),
("kind", String("duplicate_condition")),
("detail", String(relation)),
]),
)
if priority == other_priority {
findings.push(
record([
("rule", String(id)),
("kind", String("priority_tie")),
(
"detail",
String("decides by rule ID order: " + other_id + " vs " + id),
),
]),
)
}
existing.push(
record([("id", String(id)), ("priority", Number(priority))]),
)
}
None =>
conditions[canonical] = [
record([("id", String(id)), ("priority", Number(priority))]),
]
}
report.push(
record([
("id", String(id)),
("priority", Number(priority)),
("dependencies", dependencies),
("canonical", String(canonical)),
("nodes", Number(nodes.to_double())),
("depth", Number(depth.to_double())),
("flags", Array(flags)),
]),
)
index += 1
}
record([
("rules", Array(report)),
("findings", Array(findings)),
("finding_count", Number(findings.length().to_double())),
])
}