///|
/// Return static model diagnostics as JSON.
pub fn lint(model : Model) -> Json {
let findings = []
for name, decision in model.decisions {
for req in decision.all("informationRequirement") {
for reference in req.children {
let href = reference.attr("href")
if href.has_prefix("#") {
let id = href.strip_prefix("#").unwrap().to_owned()
if !model.ids.contains(id) {
findings.push(
finding(
"E_REFERENCE",
name,
"undefined reference " + href,
reference,
),
)
}
} else {
findings.push(
finding(
"E_IMPORT_UNSUPPORTED", name, "external reference", reference,
),
)
}
}
}
let expr = expression(decision) catch { _ => continue }
if expr.name == "decisionTable" {
let signatures = Map([])
let mut wildcard = false
for rule in expr.all("rule") {
let tests = rule.all("inputEntry").map(n => n.content("text"))
let signature = Json(tests).stringify()
if signatures.contains(signature) {
findings.push(
finding(
"W_IDENTICAL_RULE_OVERLAP", name, "identical predicates overlap if satisfiable",
rule,
),
)
}
signatures[signature] = true
if tests.iter().all(t => t == "-") {
wildcard = true
}
}
if !wildcard && !proves_coverage(model, expr) {
findings.push(
finding(
"W_POSSIBLE_GAP", name, "no catch-all rule; domain coverage requires manual inspection",
expr,
),
)
}
}
}
Json::array(findings)
}
///|
fn finding(
code : String,
decision : String,
message : String,
node : @xml.Node,
) -> Json {
Json::object(
Map([
("code", Json(code)),
("decision", Json(decision)),
("message", Json(message)),
("line", Json(node.line)),
("column", Json(node.column)),
]),
)
}
///|
fn enumeration(text : String) -> Array[@types.Value]? {
let result = @eval.feel_eval("[" + text + "]") catch { _ => return None }
match result {
List(values) if !values.is_empty() && values.length() <= 4096 => {
if values
.iter()
.any(v => {
v is Range(_) ||
v is List(_) ||
v is Context(_) ||
v is Function(_) ||
v is Null
}) {
return None
}
Some(values)
}
_ => None
}
}
///|
fn declared_domain(
model : Model,
name : String,
depth : Int32,
) -> Array[@types.Value]? {
if depth > 32 {
return None
}
for definition in model.root.all("itemDefinition") {
if definition.attr("name") == name {
if definition.child("allowedValues") is Some(allowed) {
return enumeration(allowed.content("text"))
}
return declared_domain(model, item_type_name(definition), depth + 1)
}
}
None
}
///|
fn proves_coverage(model : Model, table : @xml.Node) -> Bool {
let domains : Array[Array[@types.Value]] = []
let mut combinations = 1
for input in table.all("input") {
let domain = if input.child("inputValues") is Some(allowed) {
enumeration(allowed.content("text"))
} else {
let expression = match input.child("inputExpression") {
Some(e) => e.content("text")
None => return false
}
let mut found : Array[@types.Value]? = None
for data in model.root.all("inputData") {
if data.attr("name") == expression &&
data.child("variable") is Some(variable) {
found = declared_domain(model, variable.attr("typeRef"), 0)
}
}
found
}
match domain {
Some(values) => {
if values.length() > 4096 / combinations {
return false
}
combinations = combinations * values.length()
domains.push(values)
}
None => return false
}
}
if domains.is_empty() {
return false
}
let predicates = table
.all("rule")
.map(rule => rule.all("inputEntry").map(entry => entry.content("text")))
if predicates.iter().any(rule => rule.length() != domains.length()) {
return false
}
for combination = 0; combination < combinations; combination = combination + 1 {
let tuple = []
let mut index = combination
for domain in domains {
tuple.push(domain[index % domain.length()])
index = index / domain.length()
}
let mut covered = false
for rule in predicates {
let mut matched = true
for i, predicate in rule {
if !(@eval.unary_test(predicate, tuple[i], Map([])) catch {
_ => return false
}) {
matched = false
}
}
if matched {
covered = true
break
}
}
if !covered {
return false
}
}
true
}