leviath_cli/lint.rs
1//! Blueprint lint: the checks [`Blueprint::validate`] deliberately does not make.
2//!
3//! `Blueprint::validate` answers "is this manifest structurally coherent" - the
4//! layout fits, the graph resolves, fan-out wiring points at real stages. It
5//! says nothing about the fields whose *absence* quietly changes what a run
6//! does, and those are what actually bite:
7//!
8//! - a stage with no `[stages.<name>.model]` table parses fine, because the
9//! parser substitutes a default, and then runs on whatever the user's default
10//! provider happens to be
11//! - an agent-level `[model]` block is never read at all, so the author's model
12//! choice is discarded silently
13//! - a typo in `available_tools` matches nothing, and the stage just advertises
14//! one tool fewer - the model is told the tool does not exist
15//! - an autonomous stage granting `ask_user_text` parks in `WaitingInput` the
16//! first time it asks, with nobody there to answer
17//!
18//! Each of those is invisible on inspection and shows up hours later as a stuck
19//! run. This module names them at author time instead.
20//!
21//! Questions about what the author *declared* ("is there a `mode` key?") are
22//! answered from the manifest text, not from the parsed [`Blueprint`]: by then
23//! the parser has already filled in its defaults, and asking the struct cannot
24//! tell "wrote `autonomous`" apart from "wrote nothing".
25//!
26//! [`Blueprint::validate`]: leviath_core::Blueprint::validate
27
28use std::collections::{HashMap, HashSet};
29use std::path::{Path, PathBuf};
30
31use leviath_core::Blueprint;
32use leviath_core::blueprint::StageMode;
33use leviath_runtime::dynamic_interaction::BLOCKING_INTERACTION_TOOLS;
34use leviath_tools::canonical_tool_name;
35use serde::{Deserialize, Serialize};
36
37/// How much a finding matters. Only [`LintSeverity::Error`] fails
38/// `lev validate`; warnings are printed and the command still exits zero
39/// (unless `--deny-warnings` is passed); notes never fail anything.
40///
41/// Declared worst-first so sorting by it groups the report.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum LintSeverity {
45 /// The manifest says something that cannot be what the author meant - a
46 /// tool name matching nothing, a permission for a tool the stage never
47 /// granted.
48 Error,
49 /// The manifest leaves a decision to a default the author may not know
50 /// about.
51 Warning,
52 /// Nothing is wrong; the blueprint is doing something worth knowing before
53 /// you run it, like reaching outside its workdir or running a shell command
54 /// at spawn. A note must never fail a build, so `--deny-warnings` skips it.
55 Note,
56}
57
58impl LintSeverity {
59 /// Fixed-width label for the report, so the messages line up.
60 pub fn label(self) -> &'static str {
61 match self {
62 Self::Error => "ERR ",
63 Self::Warning => "WARN",
64 Self::Note => "NOTE",
65 }
66 }
67}
68
69/// One thing worth telling the author about.
70///
71/// Serialize only: `code` is a `&'static str` pointing at a literal in this
72/// file, which no deserializer can produce.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct LintFinding {
75 pub severity: LintSeverity,
76 /// Stable slug (`"unknown-tool"`), so a finding can be referenced in an
77 /// issue or grepped for in daemon logs without quoting prose.
78 pub code: &'static str,
79 /// The stage it belongs to, when it belongs to one.
80 pub stage: Option<String>,
81 /// What is wrong.
82 pub message: String,
83 /// What to do about it. Rendered on its own indented line.
84 pub fix: Option<String>,
85}
86
87impl LintFinding {
88 fn new(severity: LintSeverity, code: &'static str, message: String) -> Self {
89 Self {
90 severity,
91 code,
92 stage: None,
93 message,
94 fix: None,
95 }
96 }
97
98 fn in_stage(mut self, stage: &str) -> Self {
99 self.stage = Some(stage.to_string());
100 self
101 }
102
103 fn with_fix(mut self, fix: impl Into<String>) -> Self {
104 self.fix = Some(fix.into());
105 self
106 }
107
108 /// Whether this finding should fail the command.
109 pub fn is_error(&self) -> bool {
110 self.severity == LintSeverity::Error
111 }
112
113 /// One-line rendering for a log record: `stage 'x': message`.
114 pub fn one_line(&self) -> String {
115 match &self.stage {
116 Some(stage) => format!("stage '{stage}': {}", self.message),
117 None => self.message.clone(),
118 }
119 }
120}
121
122/// Facts about the machine the blueprint will run on, which the manifest alone
123/// cannot supply.
124///
125/// Every field is "unknown" when empty/`None`, and an unknown field skips its
126/// check entirely rather than guessing. A linter that cannot see the installed
127/// MCP servers must not claim their tools do not exist.
128#[derive(Debug, Default, Clone)]
129pub struct LintEnv {
130 /// Every tool name a manifest may legally write: canonical built-ins, their
131 /// aliases, the sub-agent tools, this agent's own `tools/*.rhai`, and any
132 /// MCP tools already resolved. Empty skips the unknown-tool check.
133 pub known_tools: HashSet<String>,
134
135 /// `(provider, model)` rows for providers whose catalog is closed enough to
136 /// check against. A provider with no row here is not checked at all, which
137 /// is what keeps open catalogs (Ollama, OpenRouter, script providers) from
138 /// producing noise.
139 pub known_models: Vec<(String, String)>,
140
141 /// The providers the blueprint names that this install can actually reach,
142 /// as answered by `ProviderRegistry::has`. `None` means nobody asked, so
143 /// the check is skipped. Resolution lives with the caller because script
144 /// providers are loaded on demand and cannot be enumerated up front.
145 pub available_providers: Option<HashSet<String>>,
146
147 /// Which of the blueprint's `[read_paths]` this install's config grants.
148 /// `None` means nobody asked (the daemon's offline lint), in which case the
149 /// check only says that a declaration needs granting. `Some(Err(..))` is a
150 /// grant list of the user's own that will not compile.
151 pub read_paths: Option<Result<crate::read_path_report::GrantReport, String>>,
152}
153
154impl LintEnv {
155 /// Everything that can be known without touching the user's config: the
156 /// built-in tools (aliases included), the sub-agent tools, the script tools
157 /// in `agent_dir/tools` and the global tools directory, and the model
158 /// catalogs this build ships.
159 ///
160 /// This is what the daemon lints against at spawn. It deliberately leaves
161 /// `available_providers` unset: the daemon already fails a spawn outright
162 /// when no listed provider is registered, so re-deriving that here would
163 /// cost a registry build per agent to say something the spawn will say
164 /// louder a moment later.
165 pub fn offline(agent_dir: &Path) -> Self {
166 let mut known_tools: HashSet<String> = leviath_tools::BuiltinTools::new(
167 leviath_tools::ToolContext::new(agent_dir.to_path_buf()),
168 )
169 .names()
170 .into_iter()
171 .collect();
172 known_tools.extend(leviath_tools::BuiltinTools::subagent_tool_names());
173
174 // The agent's own `tools/`, plus the global one every agent gets.
175 let dirs: Vec<PathBuf> = [Some(agent_dir.join("tools")), leviath_core::tools_dir()]
176 .into_iter()
177 .flatten()
178 .filter(|d| d.is_dir())
179 .collect();
180 let (set, _skipped) = leviath_scripting::ScriptToolSet::discover(&dirs);
181 known_tools.extend(set.names());
182
183 Self {
184 known_tools,
185 known_models: crate::commands::models::closed_catalog_models(),
186 available_providers: None,
187 read_paths: None,
188 }
189 }
190
191 /// Add the answer to "can this install reach the providers the blueprint
192 /// names", asked of the same registry the runtime resolves stages against
193 /// so a script provider counts exactly when it would really load.
194 pub fn with_providers(mut self, blueprint: &Blueprint, config: &crate::config::Config) -> Self {
195 let registry = crate::commands::run::build_provider_registry_from_config(config);
196 self.available_providers = Some(
197 blueprint
198 .stages
199 .iter()
200 .flat_map(|s| s.model.models.iter())
201 .map(|e| e.provider.clone())
202 .filter(|p| registry.has(p))
203 .collect(),
204 );
205 self
206 }
207
208 /// Add the answer to "does this install's config grant what the blueprint
209 /// declares under `[read_paths]`", per entry.
210 ///
211 /// Separate from [`Self::with_providers`] because it needs a workdir:
212 /// relative entries resolve against the one a run would use, which for a
213 /// command run outside a run is the directory it was invoked from.
214 pub fn with_read_paths(
215 mut self,
216 blueprint: &Blueprint,
217 config: &crate::config::Config,
218 workdir: &Path,
219 ) -> Self {
220 self.read_paths = crate::read_path_report::build(blueprint, config, workdir);
221 self
222 }
223}
224
225/// Lint `blueprint`, which was parsed from `content`.
226///
227/// The two arguments describe the same manifest: `blueprint` for what the
228/// engine will do with it, `content` for what the author actually wrote.
229pub fn lint_manifest(content: &str, blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
230 let declared = Declared::from_text(content);
231 let mut findings = Vec::new();
232
233 if declared.agent_model_block {
234 findings.push(
235 LintFinding::new(
236 LintSeverity::Warning,
237 "agent-model-block-ignored",
238 "the top-level [model] block is not read by anything: model \
239 selection is per stage"
240 .to_string(),
241 )
242 .with_fix("move it into each [stages.<name>.model] that needs it"),
243 );
244 }
245
246 findings.extend(lint_command_seeds(blueprint));
247 findings.extend(lint_read_paths(blueprint, env));
248 findings.extend(lint_graph(blueprint));
249
250 let agent_permissions = blueprint.agent_tool_permissions();
251
252 for stage in &blueprint.stages {
253 let keys = declared.stage(&stage.name);
254 findings.extend(lint_declarations(stage, keys));
255 findings.extend(lint_tools(stage, env));
256 findings.extend(lint_blocking_tools(stage));
257 findings.extend(lint_tool_policies(stage, &agent_permissions));
258 findings.extend(lint_models(stage, env));
259 }
260
261 // Worst first, stable within a severity so the order a check ran in is the
262 // order its findings read in.
263 findings.sort_by_key(|f| f.severity);
264 findings
265}
266
267// ─── Declared keys ────────────────────────────────────────────────────────────
268
269/// Which optional keys the manifest text actually writes, per stage, plus the
270/// one agent-level block that is silently discarded.
271#[derive(Debug, Default)]
272struct Declared {
273 /// A top-level `[model]` table exists. Nothing reads it.
274 agent_model_block: bool,
275 /// Per stage name, the keys that stage wrote.
276 stages: HashMap<String, StageKeys>,
277 /// The manifest text could not be re-read. Every key is then reported as
278 /// declared, so an unreadable manifest produces no declaration warnings
279 /// rather than a full set of false ones.
280 opaque: bool,
281}
282
283#[derive(Debug, Default, Clone, Copy)]
284struct StageKeys {
285 mode: bool,
286 model: bool,
287}
288
289impl Declared {
290 fn from_text(content: &str) -> Self {
291 // `toml::from_str` and not `str::parse`: the latter deserializes a bare
292 // TOML *value*, not a document, and rejects every real manifest.
293 let Ok(root) = toml::from_str::<toml::Table>(content) else {
294 return Self {
295 opaque: true,
296 ..Self::default()
297 };
298 };
299 let agent_model_block = root.get("model").is_some_and(toml::Value::is_table);
300 let stages = root
301 .get("stages")
302 .and_then(toml::Value::as_table)
303 .map(|t| {
304 t.iter()
305 .map(|(name, body)| {
306 (
307 name.clone(),
308 StageKeys {
309 mode: body.get("mode").is_some(),
310 model: body.get("model").is_some(),
311 },
312 )
313 })
314 .collect()
315 })
316 .unwrap_or_default();
317 Self {
318 agent_model_block,
319 stages,
320 opaque: false,
321 }
322 }
323
324 /// What `stage` declared. An unreadable manifest, or a stage the text has
325 /// no entry for, reports everything as declared so nothing is warned about.
326 fn stage(&self, stage: &str) -> StageKeys {
327 if self.opaque {
328 return StageKeys {
329 mode: true,
330 model: true,
331 };
332 }
333 self.stages.get(stage).copied().unwrap_or(StageKeys {
334 mode: true,
335 model: true,
336 })
337 }
338}
339
340// ─── Checks ───────────────────────────────────────────────────────────────────
341
342/// Fields the stage left to a default: `mode`, `model`, and `max_iterations`.
343fn lint_declarations(stage: &leviath_core::Stage, keys: StageKeys) -> Vec<LintFinding> {
344 let mut findings = Vec::new();
345
346 if !keys.mode {
347 findings.push(
348 LintFinding::new(
349 LintSeverity::Warning,
350 "stage-missing-mode",
351 "no mode is set, so the stage runs as autonomous".to_string(),
352 )
353 .in_stage(&stage.name)
354 .with_fix("write mode = \"autonomous\" if that is what you meant"),
355 );
356 }
357
358 if !keys.model {
359 findings.push(
360 LintFinding::new(
361 LintSeverity::Warning,
362 "stage-missing-model",
363 format!(
364 "no [stages.{}.model] block, so the stage runs on your \
365 configured default_provider, whatever that is",
366 stage.name
367 ),
368 )
369 .in_stage(&stage.name)
370 .with_fix(format!(
371 "add model = {{ models = [{{ provider = \"...\", model = \"...\" }}] }} \
372 to [stages.{}]",
373 stage.name
374 )),
375 );
376 }
377
378 // A fan_out stage does not run inference itself - it splits work and waits
379 // on its workers - so it has no iteration count to cap.
380 let counts_iterations = !matches!(stage.mode, StageMode::FanOut { .. });
381 if counts_iterations && stage.max_iterations.is_none() {
382 findings.push(
383 LintFinding::new(
384 LintSeverity::Warning,
385 "stage-missing-max-iterations",
386 "no max_iterations, so the stage is unbounded unless your config \
387 sets [limits] default_max_iterations"
388 .to_string(),
389 )
390 .in_stage(&stage.name)
391 .with_fix("give the stage a max_iterations it should never reach"),
392 );
393 }
394
395 findings
396}
397
398/// Tool names that resolve to nothing, and permissions for tools the stage
399/// never granted.
400fn lint_tools(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
401 let mut findings = Vec::new();
402
403 if !env.known_tools.is_empty() {
404 for tool in &stage.available_tools {
405 // `server__tool` is an MCP name. It resolves only once that server
406 // is installed and connected, which is not a property of the
407 // manifest, so it is never this check's business.
408 if tool.contains("__") || env.known_tools.contains(tool) {
409 continue;
410 }
411 findings.push(
412 LintFinding::new(
413 LintSeverity::Error,
414 "unknown-tool",
415 format!(
416 "grants '{tool}', which is not a built-in, a sub-agent \
417 tool, or one of this agent's own tools/*.rhai"
418 ),
419 )
420 .in_stage(&stage.name)
421 .with_fix("check the spelling, or drop the entry"),
422 );
423 }
424 }
425
426 let granted: HashSet<&str> = stage.available_tools.iter().map(String::as_str).collect();
427 for tool in stage.tool_permissions.keys() {
428 if granted.contains(tool.as_str()) {
429 continue;
430 }
431 findings.push(
432 LintFinding::new(
433 LintSeverity::Error,
434 "orphan-stage-permission",
435 format!(
436 "sets a permission for '{tool}', which it does not grant in \
437 available_tools - it reads as a grant and is not one"
438 ),
439 )
440 .in_stage(&stage.name)
441 .with_fix(format!(
442 "add '{tool}' to available_tools, or drop the permission"
443 )),
444 );
445 }
446
447 findings
448}
449
450/// Human-in-the-loop tools offered by a stage that runs with nobody attached.
451fn lint_blocking_tools(stage: &leviath_core::Stage) -> Vec<LintFinding> {
452 // Only autonomous stages are a problem: the interactive modes are where a
453 // person is expected, and a fan_out stage runs no tools of its own.
454 if !matches!(stage.mode, StageMode::Autonomous) || stage.allow_blocking_tools {
455 return Vec::new();
456 }
457 stage
458 .available_tools
459 .iter()
460 .filter(|t| BLOCKING_INTERACTION_TOOLS.contains(&canonical_tool_name(t)))
461 // A tool kept in `required_tools` is the same statement of intent
462 // `allow_blocking_tools` makes, made one tool at a time - and it is the
463 // one that also survives an unattended run, so it is worth more.
464 .filter(|t| !stage.required_tools.contains(t))
465 .map(|tool| {
466 LintFinding::new(
467 LintSeverity::Warning,
468 "blocking-tool-in-autonomous-stage",
469 format!(
470 "is autonomous but grants '{tool}', which suspends the run \
471 until a person answers"
472 ),
473 )
474 .in_stage(&stage.name)
475 .with_fix(
476 "drop the tool, switch the stage to an interactive mode, list it in \
477 required_tools so it survives an unattended run too, or set \
478 allow_blocking_tools = true to say you meant it",
479 )
480 })
481 .collect()
482}
483
484/// Permissions that do not land on the tool they look like they land on, and
485/// shell grants left to the default.
486///
487/// Policy is resolved against the name the *model* calls the tool by, which is
488/// always the canonical one. A permission written under an alias of a tool the
489/// stage granted canonically (or the reverse) is looked up under a key nothing
490/// ever asks for, so the entry has no effect at all: it reads as a decision and
491/// is not one.
492fn lint_tool_policies(
493 stage: &leviath_core::Stage,
494 agent_permissions: &HashMap<String, String>,
495) -> Vec<LintFinding> {
496 let has_policy = |name: &str| {
497 stage.tool_permissions.contains_key(name) || agent_permissions.contains_key(name)
498 };
499
500 stage
501 .available_tools
502 .iter()
503 .filter(|t| !has_policy(t))
504 .filter_map(|tool| {
505 match alias_siblings(tool).into_iter().find(|s| has_policy(s)) {
506 Some(other) => Some(
507 LintFinding::new(
508 LintSeverity::Warning,
509 "permission-name-mismatch",
510 format!(
511 "grants '{tool}' but its permission is written for \
512 '{other}'. Policy is matched on the name the model \
513 calls, which is '{tool}', so that entry has no effect"
514 ),
515 )
516 .in_stage(&stage.name)
517 .with_fix(format!("rename the permission key '{other}' to '{tool}'")),
518 ),
519 // No policy under any spelling. Only worth saying for the shell,
520 // whose default is `ask` - and an `ask` with nobody to answer
521 // waits rather than denying, so an unattended run hangs on the
522 // first command instead of failing it.
523 None if canonical_tool_name(tool) == "shell" => Some(
524 LintFinding::new(
525 LintSeverity::Warning,
526 "implicit-shell-policy",
527 format!(
528 "grants '{tool}' with no permission set for it, so it \
529 defaults to ask - and an unattended run waits on that \
530 prompt rather than being denied"
531 ),
532 )
533 .in_stage(&stage.name)
534 .with_fix(format!(
535 "set {tool} = \"allow\" or \"deny\" in [tool_permissions] or \
536 [stages.{}.tool_permissions]",
537 stage.name
538 )),
539 ),
540 None => None,
541 }
542 })
543 .collect()
544}
545
546/// Every other name for the same built-in tool: the canonical name when `name`
547/// is an alias, plus every alias of it. Never includes `name` itself.
548fn alias_siblings(name: &str) -> Vec<String> {
549 let canonical = canonical_tool_name(name);
550 std::iter::once(canonical)
551 .chain(
552 leviath_tools::TOOL_ALIASES
553 .iter()
554 .filter(|(_, c)| *c == canonical)
555 .map(|(alias, _)| *alias),
556 )
557 .filter(|s| *s != name)
558 .map(str::to_string)
559 .collect()
560}
561
562/// Regions whose `seed = { command = "..." }` runs a shell command at spawn.
563///
564/// This one is an audit line rather than a complaint: the commands run before
565/// the first inference and before any tool-approval prompt, so whoever is about
566/// to `lev add` a blueprint they did not write should see them first.
567fn lint_command_seeds(blueprint: &Blueprint) -> Vec<LintFinding> {
568 let seeds: Vec<String> = blueprint
569 .context_layout
570 .regions
571 .iter()
572 .filter_map(|r| match &r.seed {
573 Some(leviath_core::layout::RegionSeed::Command { command }) => {
574 Some(format!("{}: {command}", r.name))
575 }
576 _ => None,
577 })
578 .collect();
579 if seeds.is_empty() {
580 return Vec::new();
581 }
582 vec![
583 LintFinding::new(
584 LintSeverity::Note,
585 "command-seed",
586 format!(
587 "{} region(s) run a shell command at spawn, before the first \
588 inference and before any tool-approval prompt: {}",
589 seeds.len(),
590 seeds.join(", ")
591 ),
592 )
593 .with_fix(
594 "disable with `--no-seed-commands`, or machine-wide via \
595 `[security] allow_seed_commands = false`",
596 ),
597 ]
598}
599
600/// `[read_paths]` declarations: what the agent asks to read beyond its workdir,
601/// whether this machine's config actually grants each entry, and a sharper
602/// warning for an entry so broad it amounts to "my whole home directory" or
603/// "any absolute path".
604///
605/// The grant status is the point (issue #209). A declaration is inert on its
606/// own, and before this it took reading the config schema to find that out: the
607/// blueprint validated, the run spawned, and the first out-of-workdir read was
608/// refused with nothing said earlier. When `env` has no answer - the daemon's
609/// offline lint, which has no user config to consult - the note falls back to
610/// stating the rule.
611fn lint_read_paths(blueprint: &Blueprint, env: &LintEnv) -> Vec<LintFinding> {
612 let Some(rp) = blueprint
613 .read_paths
614 .as_ref()
615 .filter(|rp| !rp.allow.is_empty())
616 else {
617 return Vec::new();
618 };
619 let mut findings = match &env.read_paths {
620 Some(Ok(report)) => grant_findings(report),
621 // A grant list of the user's own that will not compile is a hard spawn
622 // error; saying so here is where it costs least.
623 Some(Err(e)) => vec![
624 LintFinding::new(LintSeverity::Warning, "read-paths-grant-invalid", e.clone())
625 .with_fix("fix the entry in your config.toml, or remove it"),
626 ],
627 None => vec![
628 LintFinding::new(
629 LintSeverity::Note,
630 "read-paths-declared",
631 format!(
632 "declares [read_paths] (reads outside the run workdir): {}",
633 rp.allow.join(", ")
634 ),
635 )
636 .with_fix("these are refused unless your own config grants them"),
637 ],
638 };
639 findings.extend(
640 rp.allow
641 .iter()
642 .filter(|e| read_path_entry_is_broad(e))
643 .map(|entry| {
644 LintFinding::new(
645 LintSeverity::Warning,
646 "broad-read-path",
647 format!(
648 "read_paths entry '{entry}' is very broad - it can match \
649 your entire home directory or any path on this machine"
650 ),
651 )
652 .with_fix("name the directory it actually needs")
653 }),
654 );
655 findings
656}
657
658/// One finding per declared entry, judged against the config: a note for the
659/// ones that are live, a warning naming each one that is not, and the stanza
660/// that would grant them all.
661///
662/// An entry whose pattern admits no representative path is reported as
663/// unchecked rather than as inert - claiming a working grant is broken would be
664/// worse than saying nothing.
665fn grant_findings(report: &crate::read_path_report::GrantReport) -> Vec<LintFinding> {
666 let mut findings = vec![
667 LintFinding::new(
668 LintSeverity::Note,
669 "read-paths-declared",
670 format!(
671 "declares [read_paths] (reads outside the run workdir): {}",
672 report.summary()
673 ),
674 )
675 .with_fix(match report.allow_blueprint {
676 true => "all granted by [security] allow_blueprint_read_paths = true".to_string(),
677 false => report
678 .entries
679 .iter()
680 .map(|e| format!("{}: {}", e.raw, e.status.label()))
681 .collect::<Vec<_>>()
682 .join("; "),
683 }),
684 ];
685 if report.has_ungranted() {
686 findings.push(
687 LintFinding::new(
688 LintSeverity::Warning,
689 "read-paths-not-granted",
690 format!(
691 "your config does not grant {}: reads matching them will be refused",
692 report.ungranted().join(", ")
693 ),
694 )
695 .with_fix(format!(
696 "add to your config.toml: {}",
697 report.grant_stanza().join(" ")
698 )),
699 );
700 }
701 findings
702}
703
704/// Whether a `[read_paths]` entry grants effectively unlimited read access:
705/// the home directory itself, a filesystem root, or a pattern whose first
706/// component already matches anything.
707fn read_path_entry_is_broad(entry: &str) -> bool {
708 let pattern = entry
709 .strip_prefix("glob:")
710 .or_else(|| entry.strip_prefix("regex:"))
711 .unwrap_or(entry);
712 let pattern = pattern.replace('\\', "/");
713 let trimmed = pattern.trim_end_matches('/');
714 matches!(trimmed, "~" | "")
715 || trimmed == "/**"
716 || pattern.starts_with("**")
717 || pattern.starts_with("/.*")
718 || trimmed == "/.+"
719}
720
721/// Graph shape: stages the entry can never reach, and cycles with no revisit
722/// cap. Both only mean anything for a blueprint that declares transitions at
723/// all - a linear one has no graph to walk.
724fn lint_graph(blueprint: &Blueprint) -> Vec<LintFinding> {
725 if !blueprint.stages.iter().any(|s| s.transitions.is_some()) {
726 return Vec::new();
727 }
728 let stage_names: HashSet<&str> = blueprint.stages.iter().map(|s| s.name.as_str()).collect();
729 let entry = blueprint.resolve_entry_stage_name();
730
731 // Breadth-first from the entry stage; whatever is left over is orphaned.
732 let mut reachable = HashSet::new();
733 let mut queue = std::collections::VecDeque::from([entry.clone()]);
734 while let Some(name) = queue.pop_front() {
735 if !reachable.insert(name.clone()) {
736 continue;
737 }
738 let Some(stage) = blueprint.find_stage(&name) else {
739 continue;
740 };
741 // A fan_out stage reaches its worker and merge stages through its own
742 // config rather than a transition edge, so following only `transitions`
743 // would report a perfectly wired worker as an orphan.
744 let fan_out = match &stage.mode {
745 StageMode::FanOut { config } => [
746 config.worker_stage.as_deref(),
747 config.merge_stage.as_deref(),
748 ],
749 _ => [None, None],
750 };
751 let edges = stage
752 .transitions
753 .iter()
754 .flat_map(|t| t.keys().map(String::as_str))
755 .chain(fan_out.into_iter().flatten());
756 for target in edges {
757 if !reachable.contains(target) && stage_names.contains(target) {
758 queue.push_back(target.to_string());
759 }
760 }
761 }
762
763 let mut findings: Vec<LintFinding> = blueprint
764 .stages
765 .iter()
766 .filter(|s| !reachable.contains(s.name.as_str()))
767 .map(|s| {
768 LintFinding::new(
769 LintSeverity::Warning,
770 "unreachable-stage",
771 format!("cannot be reached from entry stage '{entry}'"),
772 )
773 .in_stage(&s.name)
774 .with_fix("give some stage a transition to it, or delete it")
775 })
776 .collect();
777
778 // A pair of stages that each transition to the other, where the one being
779 // returned to has no revisit cap, can bounce forever.
780 for stage in &blueprint.stages {
781 let Some(transitions) = &stage.transitions else {
782 continue;
783 };
784 for target in transitions.keys().filter(|t| **t != stage.name) {
785 let Some(target_stage) = blueprint.find_stage(target) else {
786 continue;
787 };
788 let Some(t2) = &target_stage.transitions else {
789 continue;
790 };
791 if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
792 findings.push(
793 LintFinding::new(
794 LintSeverity::Warning,
795 "cycle-without-max-revisits",
796 format!(
797 "is in a cycle with '{}' and has no max_revisits",
798 stage.name
799 ),
800 )
801 .in_stage(target)
802 .with_fix("set max_revisits so the loop has to end"),
803 );
804 }
805 }
806 }
807
808 findings
809}
810
811/// Models and providers the install cannot resolve.
812fn lint_models(stage: &leviath_core::Stage, env: &LintEnv) -> Vec<LintFinding> {
813 let mut findings = Vec::new();
814
815 for entry in &stage.model.models {
816 // A provider with no catalog here is open-ended (Ollama serves whatever
817 // is pulled, OpenRouter's list runs to hundreds, a script provider
818 // defines its own). Checking a model against a catalog that does not
819 // claim to be complete would only produce false alarms.
820 let catalog_known = env.known_models.iter().any(|(p, _)| *p == entry.provider);
821 let listed = env
822 .known_models
823 .iter()
824 .any(|(p, m)| *p == entry.provider && *m == entry.model);
825 if catalog_known && !listed {
826 findings.push(
827 LintFinding::new(
828 LintSeverity::Warning,
829 "unknown-model",
830 format!(
831 "names {}/{}, which is not a model this build knows about",
832 entry.provider, entry.model
833 ),
834 )
835 .in_stage(&stage.name)
836 .with_fix(
837 "check `lev models list`, or `lev models list --remote` \
838 if it is newer than this build",
839 ),
840 );
841 }
842 }
843
844 // Reported per stage, not per entry: the models list is an ordered set of
845 // fallbacks, so naming a provider this install cannot reach is normal and
846 // expected as long as something later in the list answers. What is worth
847 // saying is that *nothing* in the list does, which is the shape that
848 // reaches the runtime as "no usable provider" at spawn.
849 if let Some(available) = &env.available_providers
850 && !stage.model.models.is_empty()
851 && !stage
852 .model
853 .models
854 .iter()
855 .any(|e| available.contains(&e.provider))
856 {
857 let tried: Vec<&str> = stage
858 .model
859 .models
860 .iter()
861 .map(|e| e.provider.as_str())
862 .collect();
863 findings.push(
864 LintFinding::new(
865 LintSeverity::Warning,
866 "no-reachable-provider",
867 format!(
868 "names no provider this install can reach (tried {}), so it \
869 falls back to your default model",
870 tried.join(", ")
871 ),
872 )
873 .in_stage(&stage.name)
874 .with_fix("run `lev setup` to configure one of them, or add a provider you have"),
875 );
876 }
877
878 findings
879}
880
881#[cfg(test)]
882mod tests;