///|
/// Importance of a static-analysis finding.
pub(all) enum AnalysisLevel {
Note
Warning
} derive(Eq, Debug, ToJson, FromJson)
///|
/// One actionable observation produced without evaluating input data.
pub(all) struct AnalysisFinding {
level : AnalysisLevel
code : String
message : String
span : Span
hint : String?
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Counts for the expression forms contained in a compiled program.
pub(all) struct ExpressionStatistics {
literals : Int
variables : Int
arrays : Int
members : Int
indexes : Int
optional_members : Int
optional_indexes : Int
calls : Int
unary_operations : Int
binary_operations : Int
} derive(Eq, Debug, ToJson, FromJson)
///|
/// Static description of a compiled MoonRule program.
pub(all) struct ProgramAnalysis {
source_length : Int
node_count : Int
ast_depth : Int
estimated_cost : Int
statistics : ExpressionStatistics
referenced_paths : Array[String]
called_functions : Array[String]
findings : Array[AnalysisFinding]
} derive(Eq, Debug, ToJson, FromJson)
///|
pub fn ProgramAnalysis::warning_count(self : ProgramAnalysis) -> Int {
let mut count = 0
for finding in self.findings {
if finding.level == Warning {
count = count + 1
}
}
count
}
///|
pub fn ProgramAnalysis::note_count(self : ProgramAnalysis) -> Int {
let mut count = 0
for finding in self.findings {
if finding.level == Note {
count = count + 1
}
}
count
}
///|
/// Return true when static analysis found at least one warning.
pub fn ProgramAnalysis::has_warnings(self : ProgramAnalysis) -> Bool {
self.warning_count() > 0
}
///|
/// Return true when analysis produced no notes or warnings.
pub fn ProgramAnalysis::is_clean(self : ProgramAnalysis) -> Bool {
self.findings.is_empty()
}
///|
/// Test whether the program calls a named built-in or unresolved function.
pub fn ProgramAnalysis::uses_function(
self : ProgramAnalysis,
name : String,
) -> Bool {
self.called_functions.contains(name)
}
///|
/// Test whether the exact normalized JSON path is referenced.
pub fn ProgramAnalysis::references_path(
self : ProgramAnalysis,
path : String,
) -> Bool {
self.referenced_paths.contains(path)
}
///|
/// Select findings of one importance level while preserving source order.
pub fn ProgramAnalysis::findings_at(
self : ProgramAnalysis,
level : AnalysisLevel,
) -> Array[AnalysisFinding] {
let selected = []
for finding in self.findings {
if finding.level == level {
selected.push(finding)
}
}
selected
}
///|
pub fn ProgramAnalysis::to_json_string(
self : ProgramAnalysis,
indent? : Int = 2,
) -> String {
self.to_json().stringify(indent~)
}
///|
priv struct AnalysisBuilder {
mut literals : Int
mut variables : Int
mut arrays : Int
mut members : Int
mut indexes : Int
mut optional_members : Int
mut optional_indexes : Int
mut calls : Int
mut unary_operations : Int
mut binary_operations : Int
mut estimated_cost : Int
paths : Array[String]
functions : Array[String]
findings : Array[AnalysisFinding]
}
///|
fn AnalysisBuilder::new() -> AnalysisBuilder {
{
literals: 0,
variables: 0,
arrays: 0,
members: 0,
indexes: 0,
optional_members: 0,
optional_indexes: 0,
calls: 0,
unary_operations: 0,
binary_operations: 0,
estimated_cost: 0,
paths: [],
functions: [],
findings: [],
}
}
///|
fn AnalysisBuilder::add_path(self : AnalysisBuilder, path : String) -> Unit {
if !self.paths.contains(path) {
self.paths.push(path)
}
}
///|
fn AnalysisBuilder::add_function(self : AnalysisBuilder, name : String) -> Unit {
if !self.functions.contains(name) {
self.functions.push(name)
}
}
///|
fn AnalysisBuilder::finding(
self : AnalysisBuilder,
level : AnalysisLevel,
code : String,
message : String,
span : Span,
hint? : String,
) -> Unit {
self.findings.push({ level, code, message, span, hint })
}
///|
fn static_bool(expression : Expr) -> Bool? {
match expression {
Literal(True, _) => Some(true)
Literal(False, _) => Some(false)
Unary(Not, operand, _) =>
match static_bool(operand) {
Some(value) => Some(!value)
None => None
}
_ => None
}
}
///|
fn static_number(expression : Expr) -> Double? {
match expression {
Literal(Number(value, ..), _) => Some(value)
Unary(Negate, operand, _) =>
match static_number(operand) {
Some(value) => Some(-value)
None => None
}
_ => None
}
}
///|
fn expression_path(expression : Expr) -> String? {
match expression {
Variable(name, _) => Some(name)
Member(target, name, _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "." + name)
None => None
}
OptionalMember(target, name, _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "?." + name)
None => None
}
Index(target, Literal(String(key), _), _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "[\"" + key + "\"]")
None => None
}
Index(target, Literal(Number(number, ..), _), _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "[" + number.to_string() + "]")
None => None
}
OptionalIndex(target, Literal(String(key), _), _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "?[\"" + key + "\"]")
None => None
}
OptionalIndex(target, Literal(Number(number, ..), _), _) =>
match expression_path(target) {
Some(prefix) => Some(prefix + "?[" + number.to_string() + "]")
None => None
}
_ => None
}
}
///|
fn analyze_call(
builder : AnalysisBuilder,
name : String,
arguments : Array[Expr],
span : Span,
) -> Unit {
builder.calls = builder.calls + 1
builder.estimated_cost = builder.estimated_cost + 4
builder.add_function(name)
match find_builtin_function(name) {
None =>
builder.finding(
Warning,
"A001",
"unknown function `\{name}`",
span,
hint="Use builtin_functions() to inspect the supported function catalog.",
)
Some(specification) =>
if !specification.accepts_arity(arguments.length()) {
builder.finding(
Warning,
"A002",
"function `\{name}` accepts \{specification.arity_description()} argument(s), received \{arguments.length()}",
span,
hint="Adjust the call to match \{specification.signature}.",
)
}
}
if name == "matches" {
match arguments.get(1) {
Some(Literal(String(pattern), pattern_span)) => {
let valid = try {
@string.Regex::Regex(pattern) |> ignore
true
} catch {
_ => false
}
if !valid {
builder.finding(
Warning,
"A003",
"regular-expression literal is invalid",
pattern_span,
hint="Correct the pattern before the rule is evaluated.",
)
}
if pattern.length() > 256 {
builder.finding(
Warning,
"A004",
"regular-expression literal is unusually long",
pattern_span,
hint="Prefer a focused expression and review it for excessive backtracking.",
)
}
}
Some(_) =>
builder.finding(
Note,
"A005",
"dynamic regular expression cannot be validated during compilation",
span,
hint="Use a literal pattern when early validation is important.",
)
None => ()
}
}
match arguments.get(0) {
Some(Literal(String(text), literal_span)) =>
match validate_literal_for_function(name, text) {
Some(false) =>
builder.finding(
Warning,
"A040",
"literal value can never satisfy `\{name}`",
literal_span,
hint="Correct the literal or remove the validation that is known to fail.",
)
_ => ()
}
_ => ()
}
}
///|
fn analyze_binary(
builder : AnalysisBuilder,
left : Expr,
operator : BinaryOp,
right : Expr,
span : Span,
) -> Unit {
builder.binary_operations = builder.binary_operations + 1
builder.estimated_cost = builder.estimated_cost + 2
match operator {
Or =>
match static_bool(left) {
Some(true) =>
builder.finding(
Warning,
"A010",
"right side of `||` is unreachable",
right.span(),
hint="Remove the unreachable expression or replace the constant condition.",
)
Some(false) =>
builder.finding(
Note,
"A011",
"left side of `||` is always false",
left.span(),
hint="The expression can be simplified to its right side.",
)
None => ()
}
And =>
match static_bool(left) {
Some(false) =>
builder.finding(
Warning,
"A012",
"right side of `&&` is unreachable",
right.span(),
hint="Remove the unreachable expression or replace the constant condition.",
)
Some(true) =>
builder.finding(
Note,
"A013",
"left side of `&&` is always true",
left.span(),
hint="The expression can be simplified to its right side.",
)
None => ()
}
Divide | Remainder =>
match static_number(right) {
Some(0.0) =>
builder.finding(
Warning,
"A014",
"numeric operation always divides by zero",
right.span(),
hint="Use a non-zero divisor or guard the operation.",
)
_ => ()
}
Equal | NotEqual =>
if left.span().start == right.span().start &&
left.span().end == right.span().end {
builder.finding(
Note,
"A015",
"both sides of the comparison refer to the same source range",
span,
)
}
_ => ()
}
}
///|
fn analyze_expression(builder : AnalysisBuilder, expression : Expr) -> Unit {
builder.estimated_cost = builder.estimated_cost + 1
match expression {
Literal(_, _) => builder.literals = builder.literals + 1
Variable(name, _) => {
builder.variables = builder.variables + 1
builder.add_path(name)
}
ArrayLiteral(items, span) => {
builder.arrays = builder.arrays + 1
builder.estimated_cost = builder.estimated_cost + items.length()
if items.length() > 128 {
builder.finding(
Note,
"A020",
"large array literal contains \{items.length()} items",
span,
hint="Move large reference data into the input context when it changes independently.",
)
}
for item in items {
analyze_expression(builder, item)
}
}
Member(target, _, _) => {
builder.members = builder.members + 1
match expression_path(expression) {
Some(path) => builder.add_path(path)
None => ()
}
analyze_expression(builder, target)
}
Index(target, index, _) => {
builder.indexes = builder.indexes + 1
builder.estimated_cost = builder.estimated_cost + 2
match expression_path(expression) {
Some(path) => builder.add_path(path)
None => ()
}
analyze_expression(builder, target)
analyze_expression(builder, index)
}
OptionalMember(target, _, _) => {
builder.members = builder.members + 1
builder.optional_members = builder.optional_members + 1
match expression_path(expression) {
Some(path) => builder.add_path(path)
None => ()
}
analyze_expression(builder, target)
}
OptionalIndex(target, index, _) => {
builder.indexes = builder.indexes + 1
builder.optional_indexes = builder.optional_indexes + 1
builder.estimated_cost = builder.estimated_cost + 2
match expression_path(expression) {
Some(path) => builder.add_path(path)
None => ()
}
analyze_expression(builder, target)
analyze_expression(builder, index)
}
Call(name, arguments, span) => {
analyze_call(builder, name, arguments, span)
for argument in arguments {
analyze_expression(builder, argument)
}
}
Unary(_, operand, _) => {
builder.unary_operations = builder.unary_operations + 1
analyze_expression(builder, operand)
}
Binary(left, operator, right, span) => {
analyze_binary(builder, left, operator, right, span)
analyze_expression(builder, left)
analyze_expression(builder, right)
}
}
}
///|
/// Analyze a compiled program without executing it against user data.
///
/// Findings are advisory: compile-time analysis never changes evaluation
/// semantics and can therefore be introduced safely in CI and editors.
pub fn analyze(program : Program) -> ProgramAnalysis {
let builder = AnalysisBuilder::new()
analyze_expression(builder, program.root)
if program.ast_depth() > 32 {
builder.finding(
Note,
"A030",
"expression nesting depth is \{program.ast_depth()}",
program.span(),
hint="Consider extracting intermediate fields or splitting the policy into named rules.",
)
}
if builder.estimated_cost > 500 {
builder.finding(
Note,
"A031",
"estimated evaluation cost is \{builder.estimated_cost}",
program.span(),
hint="Benchmark this expression with representative input.",
)
}
{
source_length: program.source().length(),
node_count: program.node_count(),
ast_depth: program.ast_depth(),
estimated_cost: builder.estimated_cost,
statistics: {
literals: builder.literals,
variables: builder.variables,
arrays: builder.arrays,
members: builder.members,
indexes: builder.indexes,
optional_members: builder.optional_members,
optional_indexes: builder.optional_indexes,
calls: builder.calls,
unary_operations: builder.unary_operations,
binary_operations: builder.binary_operations,
},
referenced_paths: builder.paths,
called_functions: builder.functions,
findings: builder.findings,
}
}