use harn_lexer::Span;
use harn_parser::visit;
use harn_parser::{DiagnosticCode as Code, DictEntry, Node, SNode};
use harn_vm::llm::AGENT_TOOL_HANDLER_RESULT_SCHEMA;
use crate::diagnostic::{LintDiagnostic, LintSeverity};
const RULE_NAME: &str = "untyped-tool-handler-result";
const CONVENTIONAL_OUTCOME_KEYS: &[&str] = &["ok", "success", "isError", "status", "error"];
pub(crate) fn check_untyped_tool_handler_result(
program: &[SNode],
diagnostics: &mut Vec<LintDiagnostic>,
) {
visit::walk_program(program, &mut |node| {
let Node::DictLiteral(entries) = &node.node else {
return;
};
let Some(handler) = entry_for_key(entries, "handler") else {
return;
};
let Node::Closure { body, .. } = &handler.value.node else {
return;
};
for returned in returned_dict_literals(body) {
diagnostics.push(make_diagnostic(returned));
}
});
}
fn returned_dict_literals(body: &[SNode]) -> Vec<Span> {
let mut spans = Vec::new();
for statement in body {
visit::walk_node(statement, &mut |node| {
if let Node::ReturnStmt { value: Some(value) } = &node.node {
if let Node::DictLiteral(entries) = &value.node {
if !is_handler_result_envelope(entries) {
spans.push(value.span);
}
}
}
});
}
if let Some(last) = body.last() {
if let Node::DictLiteral(entries) = &last.node {
if !is_handler_result_envelope(entries) {
spans.push(last.span);
}
}
}
spans.sort_by_key(|span| (span.start, span.end));
spans.dedup_by_key(|span| (span.start, span.end));
spans
}
fn is_handler_result_envelope(entries: &[DictEntry]) -> bool {
entry_for_key(entries, "schema").is_some_and(|entry| {
matches!(
&entry.value.node,
Node::StringLiteral(value) | Node::RawStringLiteral(value)
if value == AGENT_TOOL_HANDLER_RESULT_SCHEMA
)
})
}
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) -> LintDiagnostic {
LintDiagnostic {
code: Code::LintUntypedToolHandlerResult,
rule: RULE_NAME.into(),
message: format!(
"this tool handler returns a freeform dict, so whether the operation succeeded has to be \
inferred from key names ({}) rather than declared by the value's type.",
CONVENTIONAL_OUTCOME_KEYS.join("`, `")
),
span,
severity: LintSeverity::Warning,
suggestion: Some(
"return a typed struct whose type declares the outcome, or the \
`harn.agent_tool_handler_result.v1` envelope for a text result."
.to_string(),
),
fix: None,
}
}