Skip to main content

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;