use harn_lexer::Span;
use harn_parser::DiagnosticCode as Code;
use harn_vm::stdlib::template::lint::{ConditionShape, LintConstruct};
use crate::diagnostic::{LintDiagnostic, LintSeverity};
pub(crate) const RULE_NAME: &str = "template-variant-explosion";
pub const DEFAULT_BRANCH_THRESHOLD: usize = 3;
pub(crate) fn check(
constructs: &[LintConstruct],
threshold: usize,
source: &str,
) -> Vec<LintDiagnostic> {
let mut capability_branches: Vec<(usize, usize)> = Vec::new();
for construct in constructs {
if let LintConstruct::IfChain { branches } = construct {
for branch in branches {
if matches!(branch.condition, ConditionShape::CapabilityFlag { .. }) {
capability_branches.push((branch.line, branch.col));
}
}
}
}
if capability_branches.len() <= threshold {
return Vec::new();
}
let (line, col) = capability_branches.first().copied().unwrap_or((1, 1));
let span = anchor_span(source, line, col);
let count = capability_branches.len();
let message = format!(
"this template has {count} capability-aware branches but the configured \
threshold is {threshold}. Combinatorial explosion + drift between \
materializations is the #1 failure mode for prompt-variant systems — \
consider splitting into logical sections (`{{{{ section \"task\" }}}}`, \
`{{{{ section \"tools\" }}}}`, …) so the capability dispatch lives in \
one shared place instead of being scattered across the prompt.",
);
vec![LintDiagnostic {
code: Code::LintTemplateVariantExplosion,
rule: RULE_NAME.into(),
message,
span,
severity: LintSeverity::Warning,
suggestion: Some(
"Lift each capability branch into a logical section (see \
docs/src/prompt-templating.md#logical-sections) or raise \
`[lint] template_variant_branch_threshold` in harn.toml if the \
count is intentional."
.to_string(),
),
fix: None,
}]
}
fn anchor_span(source: &str, line: usize, col: usize) -> Span {
if line == 0 {
return Span::dummy();
}
let mut offset = 0usize;
for (current_line, src_line) in source.split_inclusive('\n').enumerate() {
if current_line + 1 == line {
let start = offset;
let end = offset + src_line.trim_end_matches('\n').len();
return Span {
start,
end,
line,
column: col.max(1),
end_line: line,
};
}
offset += src_line.len();
}
Span::dummy()
}
#[cfg(test)]
mod tests {
use crate::lint_prompt_template;
fn rule_count(d: &[crate::LintDiagnostic], rule: &str) -> usize {
d.iter().filter(|x| x.rule == rule).count()
}
fn many_branches(n: usize) -> String {
(0..n)
.map(|i| {
let flag = match i % 4 {
0 => "native_tools",
1 => "prefers_xml_scaffolding",
2 => "supports_assistant_prefill",
_ => "prefers_markdown_scaffolding",
};
format!("{{{{ if llm.capabilities.{flag} }}}}x{{{{ end }}}}")
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn under_threshold_no_diag() {
let d = lint_prompt_template(&many_branches(3), None, &[]);
assert_eq!(rule_count(&d, super::RULE_NAME), 0);
}
#[test]
fn over_threshold_emits_one_diag() {
let d = lint_prompt_template(&many_branches(5), None, &[]);
assert_eq!(rule_count(&d, super::RULE_NAME), 1);
let diag = d.iter().find(|x| x.rule == super::RULE_NAME).unwrap();
assert!(diag.message.contains("5 capability-aware branches"));
assert!(diag.message.contains("threshold is 3"));
}
#[test]
fn configurable_threshold_lifts_signal() {
let d = lint_prompt_template(&many_branches(4), Some(5), &[]);
assert_eq!(rule_count(&d, super::RULE_NAME), 0);
}
#[test]
fn provider_identity_branches_do_not_count_toward_explosion() {
let identity_branches = (0..5)
.map(|i| {
let provider = match i % 3 {
0 => "anthropic",
1 => "openai",
_ => "google",
};
format!("{{{{ if llm.provider == \"{provider}\" }}}}x{{{{ end }}}}")
})
.collect::<Vec<_>>()
.join("\n");
let d = lint_prompt_template(&identity_branches, None, &[]);
assert_eq!(rule_count(&d, super::RULE_NAME), 0);
assert_eq!(
rule_count(&d, super::super::template_provider_identity::RULE_NAME),
5,
);
}
#[test]
fn rule_can_be_disabled() {
let d = lint_prompt_template(&many_branches(6), None, &[super::RULE_NAME.to_string()]);
assert_eq!(rule_count(&d, super::RULE_NAME), 0);
}
}