use super::*;
pub(super) fn lint_declarations(stage: &leviath_core::Stage, keys: StageKeys) -> Vec<LintFinding> {
let mut findings = Vec::new();
if !keys.mode {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-mode",
"no mode is set, so the stage runs as autonomous".to_string(),
)
.in_stage(&stage.name)
.with_fix("write mode = \"autonomous\" if that is what you meant"),
);
}
if !keys.model {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-model",
format!(
"no [stages.{}.model] block, so the stage runs on your \
configured default_provider, whatever that is",
stage.name
),
)
.in_stage(&stage.name)
.with_fix(format!(
"add model = {{ models = [{{ provider = \"...\", model = \"...\" }}] }} \
to [stages.{}]",
stage.name
)),
);
}
let counts_iterations = !matches!(stage.mode, StageMode::FanOut { .. });
if counts_iterations && stage.max_iterations.is_none() {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"stage-missing-max-iterations",
"no max_iterations, so the stage is unbounded unless your config \
sets [limits] default_max_iterations"
.to_string(),
)
.in_stage(&stage.name)
.with_fix("give the stage a max_iterations it should never reach"),
);
}
findings
}
pub(super) fn lint_tools(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
let mut findings = Vec::new();
if !env.known_tools.is_empty() {
for tool in &stage.available_tools {
if tool.contains("__") || env.known_tools.contains(tool) {
continue;
}
findings.push(
LintFinding::new(
LintSeverity::Error,
"unknown-tool",
format!(
"grants '{tool}', which is not a built-in, a sub-agent \
tool, or one of this agent's own tools/*.rhai"
),
)
.in_stage(&stage.name)
.with_fix("check the spelling, or drop the entry"),
);
}
}
let granted: HashSet<&str> = stage.available_tools.iter().map(String::as_str).collect();
for tool in stage.tool_permissions.keys() {
if granted.contains(tool.as_str()) {
continue;
}
findings.push(
LintFinding::new(
LintSeverity::Error,
"orphan-stage-permission",
format!(
"sets a permission for '{tool}', which it does not grant in \
available_tools - it reads as a grant and is not one"
),
)
.in_stage(&stage.name)
.with_fix(format!(
"add '{tool}' to available_tools, or drop the permission"
)),
);
}
findings
}
pub(super) fn lint_blocking_tools(stage: &leviath_core::Stage) -> Vec<LintFinding> {
if !matches!(stage.mode, StageMode::Autonomous) || stage.allow_blocking_tools {
return Vec::new();
}
stage
.available_tools
.iter()
.filter(|t| BLOCKING_INTERACTION_TOOLS.contains(&canonical_tool_name(t)))
.filter(|t| {
!stage
.required_tools
.iter()
.any(|r| canonical_tool_name(r) == canonical_tool_name(t))
})
.map(|tool| {
LintFinding::new(
LintSeverity::Warning,
"blocking-tool-in-autonomous-stage",
format!(
"is autonomous but grants '{tool}', which suspends the run \
until a person answers"
),
)
.in_stage(&stage.name)
.with_fix(
"drop the tool, switch the stage to an interactive mode, list it in \
required_tools so it survives an unattended run too, or set \
allow_blocking_tools = true to say you meant it",
)
})
.collect()
}
pub(super) fn lint_output_stage(stage: &leviath_core::Stage) -> Vec<LintFinding> {
let mut findings = Vec::new();
let grants_submit = stage
.available_tools
.iter()
.any(|t| canonical_tool_name(t) == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL);
if stage.require_output && !grants_submit {
findings.push(
LintFinding::new(
LintSeverity::Error,
"output-missing-submit-tool",
format!(
"must produce a final output but does not grant '{}'",
leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
),
)
.in_stage(&stage.name)
.with_fix(format!(
"add '{}' to available_tools, or use mode = \"output\", which grants it",
leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
)),
);
}
if stage.output.is_some() && !stage.require_output {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"output-shape-not-required",
"declares an output shape but is not required to produce one, so the run may \
finish with nothing"
.to_string(),
)
.in_stage(&stage.name)
.with_fix("set require_output = true, or move the shape to the stage that submits"),
);
}
if stage.mode == StageMode::Output {
let modifying: Vec<&String> = stage
.available_tools
.iter()
.filter(|t| leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical_tool_name(t)))
.collect();
for tool in modifying {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"output-stage-can-modify",
format!("is an output stage but grants '{tool}', which changes the workspace"),
)
.in_stage(&stage.name)
.with_fix(
"drop the tool: an output stage reports what happened, and work done here \
lands after the review that was meant to check it",
),
);
}
}
findings
}
pub(super) fn lint_dead_end_possible(blueprint: &Blueprint) -> Vec<LintFinding> {
let mut findings = Vec::new();
for stage in &blueprint.stages {
let Some(transitions) = &stage.transitions else {
continue;
};
let normal: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
.values()
.filter(|e| {
matches!(
e.condition,
leviath_core::blueprint::TransitionCondition::Always
| leviath_core::blueprint::TransitionCondition::LlmChoice
)
})
.collect();
if normal.is_empty() {
continue; }
let all_exhaustible = normal.iter().all(|e| {
blueprint
.find_stage(&e.target)
.is_none_or(|t| t.max_revisits.is_some())
});
let has_escape = transitions.values().any(|e| {
matches!(
e.condition,
leviath_core::blueprint::TransitionCondition::DeadEnd
| leviath_core::blueprint::TransitionCondition::Error
) && blueprint
.find_stage(&e.target)
.is_some_and(|t| t.max_revisits.is_none())
});
if all_exhaustible && !has_escape {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"dead-end-possible",
"can strand the run: every normal transition's target has a max_revisits \
budget, and once they are all spent the run errors as dead-ended"
.to_string(),
)
.in_stage(&stage.name)
.with_fix(
"add a condition = \"dead_end\" edge to a stage without max_revisits \
(the output stage, usually). It is taken only when the graph would \
otherwise strand, so it is not a route the model can choose early - \
unlike a plain edge to the same stage, which is offered on every visit",
),
);
}
}
findings
}
pub(super) fn lint_output_reachable(blueprint: &Blueprint) -> Vec<LintFinding> {
let outputs: Vec<&leviath_core::Stage> = blueprint
.stages
.iter()
.filter(|s| s.mode == StageMode::Output)
.collect();
if outputs.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for output in &outputs {
let reached = blueprint.stages.iter().any(|s| {
s.name != output.name
&& s.transitions
.iter()
.flat_map(|edges| edges.values())
.any(|e| e.target == output.name)
});
let is_entry = blueprint.entry_stage.as_deref() == Some(output.name.as_str())
|| blueprint.stages.first().map(|s| s.name.as_str()) == Some(output.name.as_str());
if !reached && !is_entry {
findings.push(
LintFinding::new(
LintSeverity::Error,
"output-unreachable",
"is an output stage no edge routes to, so the run can never produce one"
.to_string(),
)
.in_stage(&output.name)
.with_fix(format!(
"add a transition to '{}' from whichever stage finishes the work",
output.name
)),
);
}
}
for stage in &blueprint.stages {
if stage.allow_complete && stage.mode != StageMode::Output {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"allow-complete-skips-output",
"may end the run itself, so the model can finish here and never reach the \
output stage"
.to_string(),
)
.in_stage(&stage.name)
.with_fix(
"drop allow_complete and route to the output stage instead - the run then \
still explains what it did",
),
);
}
}
findings
}
pub(super) fn lint_graph(blueprint: &Blueprint) -> Vec<LintFinding> {
if !blueprint.stages.iter().any(|s| s.transitions.is_some()) {
return Vec::new();
}
let stage_names: HashSet<&str> = blueprint.stages.iter().map(|s| s.name.as_str()).collect();
let entry = blueprint.resolve_entry_stage_name();
let mut reachable = HashSet::new();
let mut queue = std::collections::VecDeque::from([entry.clone()]);
while let Some(name) = queue.pop_front() {
if !reachable.insert(name.clone()) {
continue;
}
let Some(stage) = blueprint.find_stage(&name) else {
continue;
};
let fan_out = match &stage.mode {
StageMode::FanOut { config } => [
config.worker_stage.as_deref(),
config.merge_stage.as_deref(),
],
_ => [None, None],
};
let edges = stage
.transitions
.iter()
.flat_map(|t| t.keys().map(String::as_str))
.chain(fan_out.into_iter().flatten());
for target in edges {
if !reachable.contains(target) && stage_names.contains(target) {
queue.push_back(target.to_string());
}
}
}
let mut findings: Vec<LintFinding> = blueprint
.stages
.iter()
.filter(|s| !reachable.contains(s.name.as_str()))
.map(|s| {
LintFinding::new(
LintSeverity::Warning,
"unreachable-stage",
format!("cannot be reached from entry stage '{entry}'"),
)
.in_stage(&s.name)
.with_fix("give some stage a transition to it, or delete it")
})
.collect();
for stage in &blueprint.stages {
let Some(transitions) = &stage.transitions else {
continue;
};
for target in transitions.keys().filter(|t| **t != stage.name) {
let Some(target_stage) = blueprint.find_stage(target) else {
continue;
};
let Some(t2) = &target_stage.transitions else {
continue;
};
if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"cycle-without-max-revisits",
format!(
"is in a cycle with '{}' and has no max_revisits",
stage.name
),
)
.in_stage(target)
.with_fix("set max_revisits so the loop has to end"),
);
}
}
}
findings
}
pub(super) fn lint_models(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
let mut findings = Vec::new();
for entry in &stage.model.models {
let catalog_known = env.known_models.iter().any(|(p, _)| *p == entry.provider);
let listed = env
.known_models
.iter()
.any(|(p, m)| *p == entry.provider && *m == entry.model);
if catalog_known && !listed {
findings.push(
LintFinding::new(
LintSeverity::Warning,
"unknown-model",
format!(
"names {}/{}, which is not a model this build knows about",
entry.provider, entry.model
),
)
.in_stage(&stage.name)
.with_fix(
"check `lev models list`, or `lev models list --remote` \
if it is newer than this build",
),
);
}
}
if let Some(available) = &env.available_providers
&& !stage.model.models.is_empty()
&& !stage
.model
.models
.iter()
.any(|e| available.contains(&e.provider))
{
let tried: Vec<&str> = stage
.model
.models
.iter()
.map(|e| e.provider.as_str())
.collect();
findings.push(
LintFinding::new(
LintSeverity::Warning,
"no-reachable-provider",
format!(
"names no provider this install can reach (tried {}), so it \
falls back to your default model",
tried.join(", ")
),
)
.in_stage(&stage.name)
.with_fix("run `lev setup` to configure one of them, or add a provider you have"),
);
}
findings
}
pub(super) fn lint_compacted_deliverables(blueprint: &Blueprint) -> Vec<LintFinding> {
use leviath_core::blueprint::EdgeTransform;
let mut at_risk: Vec<&str> = Vec::new();
for stage in &blueprint.stages {
let layout = stage
.context_layout
.as_ref()
.unwrap_or(&blueprint.context_layout);
let bare_compact = stage
.transitions
.iter()
.flat_map(|edges| edges.values())
.any(|e| matches!(e.transform, EdgeTransform::Compact { .. }));
if !bare_compact {
continue;
}
for region in &layout.regions {
if region.required
&& region.summarizable
&& leviath_runtime::is_stage_specific(®ion.kind)
&& !at_risk.contains(®ion.name.as_str())
{
at_risk.push(region.name.as_str());
}
}
}
at_risk
.into_iter()
.map(|region| {
LintFinding::new(
LintSeverity::Warning,
"compact-summarizes-deliverable",
format!(
"region '{region}' is declared required - a stage must populate it - \
and a `transform = \"compact\"` edge would hand it to the summarizer \
on the way out, so whatever the stage wrote reaches later stages \
paraphrased"
),
)
.with_fix(format!(
"add summarizable = false to [context.regions] {region} if its content \
does not survive a rewrite, or name the regions to summarize with \
transform = \"custom\""
))
})
.collect()
}