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