use harn_lexer::Span;
use harn_parser::visit;
use harn_parser::{
BindingPattern, DiagnosticCode as Code, DictEntry, Node, SNode, ShapeField, TypeExpr,
};
use std::collections::HashMap;
use crate::diagnostic::{LintDiagnostic, LintSeverity};
use crate::linter::harness_facts::HarnessFacts;
const RULE_NAME: &str = "unbounded-native-decision-state";
const NATIVE_DECISION_BACKEND: &str = "native_decision";
const EVALUATION_SITES: &[(&str, usize, usize, &str)] = &[
("evaluate", 1, 3, "state"),
("evaluate_predicate", 2, 3, "input"),
];
const BOUNDED_SCALARS: &[&str] = &["int", "float", "bool", "nil"];
const UNBOUNDED_SCALARS: &[&str] = &["string", "any", "unknown", "bytes"];
pub(crate) fn check_unbounded_native_decision_state(
program: &[SNode],
harness: &HarnessFacts,
diagnostics: &mut Vec<LintDiagnostic>,
) {
let types = DeclaredTypes::collect(program);
visit::walk_program(program, &mut |node| {
let Some((args, input_index, policy_index, parameter)) = evaluation_site(node, harness)
else {
return;
};
let (Some(input), Some(policy)) = (args.get(input_index), args.get(policy_index)) else {
return;
};
if !types.names_native_decision(policy) {
return;
}
let Node::Identifier(name) = &input.node else {
return;
};
let Some(declared) = types.bindings.get(name) else {
return;
};
if let Some(unbounded) = types.unbounded_reason(declared) {
diagnostics.push(make_diagnostic(input.span, parameter, &unbounded));
}
});
}
fn evaluation_site<'node>(
node: &'node SNode,
harness: &HarnessFacts,
) -> Option<(&'node [SNode], usize, usize, &'static str)> {
let (object, method, args) = match &node.node {
Node::MethodCall {
object,
method,
args,
}
| Node::OptionalMethodCall {
object,
method,
args,
} => (object, method, args),
_ => return None,
};
if harness.capability_of(object) != Some("llm") {
return None;
}
EVALUATION_SITES
.iter()
.find(|(name, ..)| name == method)
.map(|(_, input, policy, parameter)| (args.as_slice(), *input, *policy, *parameter))
}
struct DeclaredTypes {
bindings: HashMap<String, TypeExpr>,
aliases: HashMap<String, TypeExpr>,
records: HashMap<String, Vec<DictEntry>>,
}
impl DeclaredTypes {
fn collect(program: &[SNode]) -> Self {
let mut types = DeclaredTypes {
bindings: HashMap::new(),
aliases: HashMap::new(),
records: HashMap::new(),
};
visit::walk_program(program, &mut |node| match &node.node {
Node::LetBinding {
pattern,
type_ann,
value,
..
}
| Node::ConstBinding {
pattern,
type_ann,
value,
..
} => {
let BindingPattern::Identifier(name) = pattern else {
return;
};
if let Some(type_ann) = type_ann {
types.bindings.insert(name.clone(), type_ann.clone());
}
if let Node::DictLiteral(entries) = &value.node {
types.records.insert(name.clone(), entries.clone());
}
}
Node::TypeDecl {
name, type_expr, ..
} => {
types.aliases.insert(name.clone(), type_expr.clone());
}
Node::FnDecl { params, .. }
| Node::Pipeline { params, .. }
| Node::ToolDecl { params, .. } => {
for param in params {
if let Some(type_expr) = ¶m.type_expr {
types.bindings.insert(param.name.clone(), type_expr.clone());
}
}
}
_ => {}
});
types
}
fn names_native_decision(&self, policy: &SNode) -> bool {
let entries = match &policy.node {
Node::DictLiteral(entries) => entries.as_slice(),
Node::Identifier(name) => match self.records.get(name) {
Some(entries) => entries.as_slice(),
None => return false,
},
_ => return false,
};
entry_for_key(entries, "backend").is_some_and(|entry| {
matches!(
&entry.value.node,
Node::StringLiteral(value) | Node::RawStringLiteral(value)
if value == NATIVE_DECISION_BACKEND
)
})
}
fn unbounded_reason(&self, declared: &TypeExpr) -> Option<String> {
self.reason(declared, 0)
}
fn reason(&self, declared: &TypeExpr, depth: usize) -> Option<String> {
if depth > 16 {
return None;
}
match declared {
TypeExpr::Named(name) => {
if BOUNDED_SCALARS.contains(&name.as_str()) {
return None;
}
if UNBOUNDED_SCALARS.contains(&name.as_str()) {
return Some(name.clone());
}
if name == "list" || name == "dict" {
return Some(name.clone());
}
let alias = self.aliases.get(name)?;
self.reason(alias, depth + 1)
}
TypeExpr::List(inner) | TypeExpr::Iter(inner) | TypeExpr::Stream(inner) => {
let _ = inner;
Some(type_name(declared).to_string())
}
TypeExpr::DictType(..) => Some("dict".to_string()),
TypeExpr::Generator(_) => Some("Generator".to_string()),
TypeExpr::OpenShape { .. } => Some("open record".to_string()),
TypeExpr::Shape(fields) => fields
.iter()
.find_map(|field: &ShapeField| self.reason(&field.type_expr, depth + 1)),
TypeExpr::Tuple(items) => items.iter().find_map(|item| self.reason(item, depth + 1)),
TypeExpr::Union(parts) | TypeExpr::Intersection(parts) => {
parts.iter().find_map(|part| self.reason(part, depth + 1))
}
TypeExpr::Applied { name, args } => {
if name == "list" || name == "dict" {
return Some(name.clone());
}
args.iter().find_map(|arg| self.reason(arg, depth + 1))
}
_ => None,
}
}
}
fn type_name(declared: &TypeExpr) -> &'static str {
match declared {
TypeExpr::List(_) => "list",
TypeExpr::Iter(_) => "iter",
TypeExpr::Stream(_) => "Stream",
_ => "type",
}
}
fn entry_for_key<'a>(entries: &'a [DictEntry], key: &str) -> Option<&'a DictEntry> {
entries
.iter()
.find(|entry| key_name(&entry.key).as_deref() == Some(key))
}
fn key_name(node: &SNode) -> Option<String> {
match &node.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
Some(value.clone())
}
_ => None,
}
}
fn make_diagnostic(span: Span, parameter: &str, unbounded: &str) -> LintDiagnostic {
LintDiagnostic {
code: Code::LintUnboundedNativeDecisionState,
rule: RULE_NAME.into(),
message: format!(
"this policy names the `{NATIVE_DECISION_BACKEND}` route, whose admission bounds \
encoded input, but the declared type of `{parameter}` contains `{unbounded}` and so \
has no finite size bound."
),
span,
severity: LintSeverity::Warning,
suggestion: Some(
"narrow the declared type so its encoded size is bounded, or split the input with \
`evaluation_windows` from `std/predicate`, which sizes each window with the \
evaluator's own estimator."
.to_string(),
),
fix: None,
}
}