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;
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;