Skip to main content

mars_agents/cli/
check.rs

1//! `mars check [PATH]` — validate a source package before publishing.
2//!
3//! Scans a directory as a mars source package
4//! (`agents/*.md`, `skills/*/SKILL.md`, or a flat root `SKILL.md`)
5//! and validates structure, frontmatter, and internal skill dependencies.
6//! No config or lock file needed — works on raw source directories.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::discover;
14use crate::error::MarsError;
15use crate::frontmatter;
16
17use super::output;
18
19/// Arguments for `mars check`.
20#[derive(Debug, clap::Args)]
21pub struct CheckArgs {
22    /// Directory to validate as a source package (default: current directory).
23    pub path: Option<PathBuf>,
24}
25
26#[derive(Debug, Serialize)]
27pub(crate) struct CheckReport {
28    agents: usize,
29    skills: usize,
30    pub(crate) errors: Vec<String>,
31    warnings: Vec<String>,
32}
33
34/// Run `mars check`.
35pub fn run(args: &CheckArgs, json: bool) -> Result<i32, MarsError> {
36    let base = match &args.path {
37        Some(p) => {
38            if p.is_absolute() {
39                p.clone()
40            } else {
41                std::env::current_dir()?.join(p)
42            }
43        }
44        None => std::env::current_dir()?,
45    };
46
47    if !base.is_dir() {
48        return Err(MarsError::Config(crate::error::ConfigError::Invalid {
49            message: format!("{} is not a directory", base.display()),
50        }));
51    }
52
53    let report = check_dir(&base)?;
54
55    if json {
56        output::print_json(&report);
57    } else {
58        println!("  {} agents, {} skills", report.agents, report.skills);
59        println!(
60            "  source package validates for .mars/ canonical store and native harness targets"
61        );
62        println!();
63
64        if report.errors.is_empty() && report.warnings.is_empty() {
65            output::print_success("all checks passed");
66        } else {
67            for e in &report.errors {
68                output::print_error(e);
69            }
70            for w in &report.warnings {
71                output::print_warn(w);
72            }
73            if !report.errors.is_empty() {
74                println!();
75                println!("  {} error(s) found", report.errors.len());
76            }
77        }
78    }
79
80    if report.errors.is_empty() {
81        Ok(0)
82    } else {
83        Ok(1)
84    }
85}
86
87pub(crate) fn check_dir(base: &Path) -> Result<CheckReport, MarsError> {
88    let skills_dir = base.join("skills");
89
90    let mut errors: Vec<String> = Vec::new();
91    let mut warnings: Vec<String> = Vec::new();
92
93    let discovered = discover::discover_resolved_source(base, None)?;
94
95    // ── Validate discovered agents/skills ────────────────────────────
96    let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
97    let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
98    let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
99
100    for item in discovered {
101        let path = base.join(&item.source_path);
102        match item.id.kind {
103            crate::lock::ItemKind::Agent => {
104                if super::is_symlink(&path) {
105                    let name = path
106                        .file_stem()
107                        .and_then(|n| n.to_str())
108                        .unwrap_or_default();
109                    warnings.push(format!(
110                        "skipping symlinked agent `{name}` — source packages should not contain symlinks"
111                    ));
112                    continue;
113                }
114
115                let filename = path
116                    .file_stem()
117                    .and_then(|n| n.to_str())
118                    .unwrap_or_default()
119                    .to_string();
120
121                match std::fs::read_to_string(&path) {
122                    Ok(content) => match frontmatter::parse(&content) {
123                        Ok(fm) => {
124                            let name = fm
125                                .name()
126                                .map(str::to_string)
127                                .unwrap_or_else(|| filename.clone());
128
129                            if fm.name().is_none() {
130                                warnings.push(format!(
131                                    "agent `{filename}` has no `name` in frontmatter"
132                                ));
133                            }
134
135                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
136                                warnings.push(format!("agent `{name}` has no `description`"));
137                            }
138
139                            if fm.name().is_some() && name != filename {
140                                warnings.push(format!(
141                                    "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
142                                ));
143                            }
144
145                            if let Some(existing) = agent_names.get(&name) {
146                                errors.push(format!(
147                                    "duplicate agent name `{name}` in {} and {}",
148                                    existing.display(),
149                                    path.display()
150                                ));
151                            } else {
152                                agent_names.insert(name.clone(), path.clone());
153                            }
154
155                            let skills = fm.skills();
156                            if !skills.is_empty() {
157                                agent_skill_refs.push((name, skills));
158                            }
159                        }
160                        Err(e) => {
161                            errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
162                        }
163                    },
164                    Err(e) => {
165                        errors.push(format!("cannot read {}: {e}", path.display()));
166                    }
167                }
168            }
169            crate::lock::ItemKind::Skill => {
170                let (dirname, skill_md, duplicate_path) = if item.source_path
171                    == std::path::Path::new(".")
172                {
173                    let dirname = item.id.name.to_string();
174                    (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
175                } else {
176                    if super::is_symlink(&path) {
177                        let name = path
178                            .file_name()
179                            .and_then(|n| n.to_str())
180                            .unwrap_or_default();
181                        warnings.push(format!(
182                            "skipping symlinked skill `{name}` — source packages should not contain symlinks"
183                        ));
184                        continue;
185                    }
186                    let dirname = path
187                        .file_name()
188                        .and_then(|n| n.to_str())
189                        .unwrap_or_default()
190                        .to_string();
191                    (dirname, path.join("SKILL.md"), path.clone())
192                };
193
194                match std::fs::read_to_string(&skill_md) {
195                    Ok(content) => match frontmatter::parse(&content) {
196                        Ok(fm) => {
197                            let name = fm
198                                .name()
199                                .map(str::to_string)
200                                .unwrap_or_else(|| dirname.clone());
201
202                            if fm.name().is_none() {
203                                warnings.push(format!(
204                                    "skill `{dirname}` has no `name` in frontmatter"
205                                ));
206                            }
207
208                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
209                                warnings.push(format!("skill `{name}` has no `description`"));
210                            }
211
212                            if fm.name().is_some() && name != dirname {
213                                warnings.push(format!(
214                                    "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
215                                ));
216                            }
217
218                            if let Some(existing) = skill_names.get(&name) {
219                                errors.push(format!(
220                                    "duplicate skill name `{name}` in {} and {}",
221                                    existing.display(),
222                                    duplicate_path.display()
223                                ));
224                            } else {
225                                skill_names.insert(name, duplicate_path);
226                            }
227                        }
228                        Err(e) => {
229                            errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
230                        }
231                    },
232                    Err(e) => {
233                        errors.push(format!("cannot read {}: {e}", skill_md.display()));
234                    }
235                }
236            }
237            // New kinds not yet subject to source-package checks.
238            crate::lock::ItemKind::Hook
239            | crate::lock::ItemKind::McpServer
240            | crate::lock::ItemKind::BootstrapDoc => {}
241        }
242    }
243
244    // Structural validation for nested skill layout:
245    // if skills/* directories exist, each must contain SKILL.md.
246    if skills_dir.is_dir() {
247        let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
248            .filter_map(|e| e.ok())
249            .filter(|e| e.path().is_dir())
250            .collect();
251        entries.sort_by_key(|e| e.file_name());
252        for entry in entries {
253            let path = entry.path();
254            let dirname = path
255                .file_name()
256                .and_then(|n| n.to_str())
257                .unwrap_or_default();
258            if !path.join("SKILL.md").exists() {
259                errors.push(format!("skill `{dirname}` is missing SKILL.md"));
260            }
261        }
262    }
263
264    let agent_count = agent_names.len();
265    let skill_count = skill_names.len();
266
267    // ── Empty package check ──────────────────────────────────────────
268    if agent_count == 0 && skill_count == 0 {
269        errors.push("no agents or skills found — is this a mars source package?".to_string());
270    }
271
272    // ── Skill dependency check ───────────────────────────────────────
273    let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
274
275    match has_package_dependencies(base) {
276        Ok(true) => {
277            // Graph-backed validation: resolve deps fresh from constraints, check
278            // skill refs against local skills + all resolved dependency packages.
279            match resolve_available_skills(base) {
280                Ok(graph_skills) => {
281                    for (agent_name, skills) in &agent_skill_refs {
282                        for skill in skills {
283                            if !available.contains(skill.as_str())
284                                && !graph_skills.contains_key(skill)
285                            {
286                                errors.push(format!(
287                                    "agent `{agent_name}` references skill `{skill}` not found in local package or dependencies\n  searched: {}\n  hint: add the skill's source package as a dependency, or remove the skill reference",
288                                    format_searched_packages(&graph_skills)
289                                ));
290                            }
291                        }
292                    }
293                }
294                Err(resolve_err) => {
295                    errors.push(format!(
296                        "dependency graph resolution failed: {resolve_err}\n  hint: check network access, or use `mars version --force` to bypass the publish gate"
297                    ));
298                }
299            }
300        }
301        Ok(false) => {
302            // No [dependencies] — local-only validation, emit warnings for external refs.
303            for (agent_name, skills) in &agent_skill_refs {
304                for skill in skills {
305                    if !available.contains(skill.as_str()) {
306                        warnings.push(format!(
307                            "external dependency: `{skill}` (referenced by: {agent_name})"
308                        ));
309                    }
310                }
311            }
312        }
313        Err(config_err) => {
314            errors.push(format!(
315                "failed to load mars.toml for dependency checks: {config_err}\n  hint: fix mars.toml syntax (Windows paths in TOML must use `/` or escaped `\\\\`)"
316            ));
317        }
318    }
319
320    // ── Output ───────────────────────────────────────────────────────
321    Ok(CheckReport {
322        agents: agent_count,
323        skills: skill_count,
324        errors,
325        warnings,
326    })
327}
328
329/// Check if mars.toml has `[package]` and at least one `[dependencies]` entry.
330///
331/// Both are required to trigger graph-backed validation: `[package]` indicates
332/// this is a publishable source package, and `[dependencies]` means there are
333/// skills that could come from external packages.
334fn has_package_dependencies(base: &Path) -> Result<bool, MarsError> {
335    match crate::config::load(base) {
336        Ok(config) => Ok(config.package.is_some() && !config.dependencies.is_empty()),
337        Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(false),
338        Err(err) => Err(err),
339    }
340}
341
342/// Resolve the dependency graph and collect available skills, respecting package filters.
343///
344/// Returns a map of `skill_name → (source_name, version_string)`.
345/// Fails closed — if resolution cannot complete, returns an error.
346///
347/// Uses only `[dependencies]` from mars.toml — excludes `[local-dependencies]` (dev-only)
348/// and ignores mars.local.toml overrides (local dev paths). This matches what consumers
349/// see when they depend on this package.
350fn resolve_available_skills(base: &Path) -> Result<HashMap<String, (String, String)>, MarsError> {
351    use crate::resolve::{ResolveOptions, resolve};
352    use crate::source::GlobalCache;
353    use crate::sync::provider::RealSourceProvider;
354
355    let config = crate::config::load(base)?;
356    // Publish gate: use only mars.toml [dependencies].
357    // Strip [local-dependencies] (dev-only, not exported to consumers) and skip
358    // mars.local.toml (local dev path overrides that don't exist on consumers).
359    let mut publish_config = config.clone();
360    publish_config.local_dependencies.clear();
361    let effective = crate::config::merge(publish_config, crate::config::LocalConfig::default())?;
362
363    let cache = GlobalCache::new()?;
364    let provider = RealSourceProvider {
365        cache: &cache,
366        project_root: base,
367    };
368    let mut diag = crate::diagnostic::DiagnosticCollector::new();
369    let options = ResolveOptions::default(); // no lock, not frozen, not maximizing
370
371    let graph = resolve(&effective, &provider, None, &options, &mut diag)?;
372
373    let mut skills: HashMap<String, (String, String)> = HashMap::new();
374    for (source_name, node) in &graph.nodes {
375        let discovered =
376            crate::discover::discover_resolved_source(&node.rooted_ref.package_root, None)?;
377        let package_filters = graph.filters.get(source_name);
378        for item in &discovered {
379            if item.id.kind == crate::lock::ItemKind::Skill
380                && item_passes_filters(item, package_filters)
381            {
382                let version_str = node
383                    .resolved_ref
384                    .version
385                    .as_ref()
386                    .map(|v| v.to_string())
387                    .unwrap_or_else(|| "unknown".to_string());
388                skills.insert(
389                    item.id.name.to_string(),
390                    (source_name.to_string(), version_str),
391                );
392            }
393        }
394    }
395
396    Ok(skills)
397}
398
399/// Returns true if a skill item would be installed given the accumulated filter constraints.
400///
401/// Filters are accumulated with OR semantics: an item passes if ANY filter in the list
402/// would include it (multiple requests for the same package may each install different
403/// subsets, and a skill available from any of them is usable).
404///
405/// Matches real install semantics from `seed_items_for_request`: `Exclude` checks both
406/// skill name and source path so path-based excludes are honoured in the publish gate.
407fn item_passes_filters(
408    item: &crate::discover::DiscoveredItem,
409    filters: Option<&Vec<crate::config::FilterMode>>,
410) -> bool {
411    let Some(filters) = filters else {
412        return true; // no filter constraint → all items pass
413    };
414    filters.iter().any(|filter| match filter {
415        crate::config::FilterMode::All => true,
416        crate::config::FilterMode::Include { skills, .. } => skills.contains(&item.id.name),
417        crate::config::FilterMode::Exclude(excluded) => {
418            let source_path = item.source_path.to_string_lossy();
419            !excluded.iter().any(|e| {
420                *e == item.id.name || crate::target::paths_equivalent(e.as_ref(), &source_path)
421            })
422        }
423        crate::config::FilterMode::OnlySkills => true,
424        crate::config::FilterMode::OnlyAgents => false,
425    })
426}
427
428fn format_searched_packages(graph_skills: &HashMap<String, (String, String)>) -> String {
429    let mut packages: Vec<(&str, &str)> = graph_skills
430        .values()
431        .map(|(name, ver)| (name.as_str(), ver.as_str()))
432        .collect();
433    packages.sort();
434    packages.dedup();
435    if packages.is_empty() {
436        "no dependency packages resolved".to_string()
437    } else {
438        packages
439            .iter()
440            .map(|(name, ver)| format!("{name}@{ver}"))
441            .collect::<Vec<_>>()
442            .join(", ")
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::path::Path;
449
450    use tempfile::TempDir;
451
452    fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
453        let agents = path.join("agents");
454        std::fs::create_dir_all(&agents).unwrap();
455        let skills_str = skills.join(", ");
456        std::fs::write(
457            agents.join(format!("{filename}.md")),
458            format!(
459                "---\nname: {filename}\ndescription: test agent\nskills: [{skills_str}]\n---\n# Agent"
460            ),
461        )
462        .unwrap();
463    }
464
465    /// Create a minimal path-dep source package with the given skills.
466    fn write_dep_package(path: &Path, name: &str, version: &str, skills: &[&str]) {
467        std::fs::create_dir_all(path).unwrap();
468        std::fs::write(
469            path.join("mars.toml"),
470            format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n\n[dependencies]\n"),
471        )
472        .unwrap();
473        for skill_name in skills {
474            let skill_dir = path.join("skills").join(skill_name);
475            std::fs::create_dir_all(&skill_dir).unwrap();
476            std::fs::write(
477                skill_dir.join("SKILL.md"),
478                format!("---\nname: {skill_name}\ndescription: test skill\n---\n# Skill"),
479            )
480            .unwrap();
481        }
482    }
483
484    fn toml_path(path: &Path) -> String {
485        path.to_string_lossy().replace('\\', "/")
486    }
487
488    // ── Structural checks (unchanged) ─────────────────────────────────
489
490    #[cfg(unix)]
491    #[test]
492    fn check_skips_symlinked_agent() {
493        let dir = TempDir::new().unwrap();
494        let agents = dir.path().join("agents");
495        std::fs::create_dir_all(&agents).unwrap();
496
497        std::fs::write(
498            agents.join("real.md"),
499            "---\nname: real\ndescription: real agent\n---\n# Real",
500        )
501        .unwrap();
502        std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
503
504        let args = super::CheckArgs {
505            path: Some(dir.path().to_path_buf()),
506        };
507        let code = super::run(&args, true).unwrap();
508        assert_eq!(code, 0);
509    }
510
511    #[cfg(unix)]
512    #[test]
513    fn check_skips_symlinked_skill() {
514        let dir = TempDir::new().unwrap();
515        let skills = dir.path().join("skills");
516        let real_skill = skills.join("real-skill");
517        std::fs::create_dir_all(&real_skill).unwrap();
518        std::fs::write(
519            real_skill.join("SKILL.md"),
520            "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
521        )
522        .unwrap();
523        std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
524
525        let agents = dir.path().join("agents");
526        std::fs::create_dir_all(&agents).unwrap();
527        std::fs::write(
528            agents.join("coder.md"),
529            "---\nname: coder\ndescription: agent\n---\n# Coder",
530        )
531        .unwrap();
532
533        let args = super::CheckArgs {
534            path: Some(dir.path().to_path_buf()),
535        };
536        let code = super::run(&args, true).unwrap();
537        assert_eq!(code, 0);
538    }
539
540    #[test]
541    fn check_accepts_flat_skill_repo() {
542        let dir = TempDir::new().unwrap();
543        std::fs::write(
544            dir.path().join("SKILL.md"),
545            "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
546        )
547        .unwrap();
548
549        let args = super::CheckArgs {
550            path: Some(dir.path().to_path_buf()),
551        };
552        let code = super::run(&args, true).unwrap();
553        assert_eq!(code, 0);
554    }
555
556    // ── P3: No [dependencies] → local-only path, external refs are warnings ──
557
558    #[test]
559    fn check_no_dependencies_warns_for_external_skill() {
560        // No mars.toml → has_package_dependencies returns false → warning path.
561        let dir = TempDir::new().unwrap();
562        write_agent(dir.path(), "coder", &["missing-skill"]);
563
564        let report = super::check_dir(dir.path()).unwrap();
565        assert!(
566            report.errors.is_empty(),
567            "expected no errors in local-only mode: {:?}",
568            report.errors
569        );
570        let has_warning = report
571            .warnings
572            .iter()
573            .any(|w| w.contains("external dependency: `missing-skill`"));
574        assert!(
575            has_warning,
576            "expected warning for missing-skill: {:?}",
577            report.warnings
578        );
579    }
580
581    #[test]
582    fn check_warns_for_truly_missing_external_skill() {
583        // No mars.toml → local-only path → skill ref that isn't local → warning.
584        let dir = TempDir::new().unwrap();
585        write_agent(dir.path(), "coder", &["missing-skill"]);
586
587        let report = super::check_dir(dir.path()).unwrap();
588        let has_missing_warning = report
589            .warnings
590            .iter()
591            .any(|w| w.contains("external dependency: `missing-skill`"));
592
593        assert!(
594            has_missing_warning,
595            "expected missing external dependency warning, got: {:?}",
596            report.warnings
597        );
598    }
599
600    // ── P1 + P4 + P9: [dependencies] present, resolution fails → error with hint ─
601
602    #[test]
603    fn check_with_unresolvable_dep_fails_closed_with_remediation_hint() {
604        // P1: mars.toml with [dependencies] triggers graph resolution.
605        // P4: resolution fails (non-existent path) → fail-closed error.
606        // P9: error message includes remediation ("mars version --force").
607        let dir = TempDir::new().unwrap();
608        write_agent(dir.path(), "coder", &["some-skill"]);
609        std::fs::write(
610            dir.path().join("mars.toml"),
611            // [package] required to trigger graph-backed validation.
612            // Absolute path that does not exist — resolution must fail.
613            "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-mars-dep-xyz-abc\" }\n",
614        )
615        .unwrap();
616
617        let report = super::check_dir(dir.path()).unwrap();
618        assert!(
619            !report.errors.is_empty(),
620            "expected errors when dep cannot be resolved"
621        );
622        let joined = report.errors.join("\n");
623        assert!(
624            joined.contains("mars version --force"),
625            "error must include remediation hint: {joined}"
626        );
627    }
628
629    // ── P2 + P8: [dependencies] resolve, skill missing from graph → error ────────
630
631    #[test]
632    fn check_missing_skill_in_resolved_graph_is_error() {
633        // P2: skill not in graph → error (not warning).
634        // P8: error message includes agent name, skill name, searched packages.
635        let dir = TempDir::new().unwrap();
636        let dep_dir = TempDir::new().unwrap();
637
638        // Path dep provides "provided-skill", NOT "missing-skill".
639        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["provided-skill"]);
640
641        write_agent(dir.path(), "coder", &["missing-skill"]);
642        std::fs::write(
643            dir.path().join("mars.toml"),
644            format!(
645                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
646                toml_path(dep_dir.path())
647            ),
648        )
649        .unwrap();
650
651        let report = super::check_dir(dir.path()).unwrap();
652        assert!(
653            !report.errors.is_empty(),
654            "expected error for missing skill, got: {:?}",
655            report.errors
656        );
657        let joined = report.errors.join("\n");
658        // P8: error includes agent name, skill name, searched packages, and remediation.
659        assert!(
660            joined.contains("coder"),
661            "error must name the agent: {joined}"
662        );
663        assert!(
664            joined.contains("missing-skill"),
665            "error must name the missing skill: {joined}"
666        );
667        assert!(
668            joined.contains("searched:"),
669            "error must list searched packages: {joined}"
670        );
671        assert!(
672            joined.contains("hint:"),
673            "error must include remediation guidance: {joined}"
674        );
675        // Warnings must NOT contain missing-skill (it is now an error).
676        let has_warning = report.warnings.iter().any(|w| w.contains("missing-skill"));
677        assert!(
678            !has_warning,
679            "missing skill must be error, not warning: {:?}",
680            report.warnings
681        );
682    }
683
684    // ── Skill provided by path dep passes (graph-backed success) ─────────────────
685
686    #[test]
687    fn check_skill_provided_by_path_dep_passes() {
688        // When the skill is found in a resolved path dependency, no error.
689        let dir = TempDir::new().unwrap();
690        let dep_dir = TempDir::new().unwrap();
691
692        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
693        write_agent(dir.path(), "coder", &["ext-skill"]);
694        std::fs::write(
695            dir.path().join("mars.toml"),
696            format!(
697                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
698                toml_path(dep_dir.path())
699            ),
700        )
701        .unwrap();
702
703        let report = super::check_dir(dir.path()).unwrap();
704        assert!(
705            report.errors.is_empty(),
706            "expected no errors when skill is in dep: {:?}",
707            report.errors
708        );
709    }
710
711    // ── Fix 1: Filter bypass — excluded skill must not satisfy a ref ──────────────
712
713    #[test]
714    fn check_excluded_skill_in_dep_is_not_available() {
715        // A skill that exists in the dep package but is excluded via filter
716        // must not satisfy an agent skill reference — the filter bypass is the bug.
717        let dir = TempDir::new().unwrap();
718        let dep_dir = TempDir::new().unwrap();
719
720        // Dep provides "ext-skill" and "other-skill", but consumer excludes "ext-skill".
721        write_dep_package(
722            dep_dir.path(),
723            "dep-pkg",
724            "0.1.0",
725            &["ext-skill", "other-skill"],
726        );
727        write_agent(dir.path(), "coder", &["ext-skill"]);
728        std::fs::write(
729            dir.path().join("mars.toml"),
730            format!(
731                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", exclude = [\"ext-skill\"] }}\n",
732                toml_path(dep_dir.path())
733            ),
734        )
735        .unwrap();
736
737        let report = super::check_dir(dir.path()).unwrap();
738        assert!(
739            !report.errors.is_empty(),
740            "excluded skill must not satisfy ref — expected error, got none: {:?}",
741            report.errors
742        );
743        let joined = report.errors.join("\n");
744        assert!(
745            joined.contains("ext-skill"),
746            "error must mention the missing skill: {joined}"
747        );
748    }
749
750    #[test]
751    fn check_only_agents_filter_makes_skills_unavailable() {
752        // only_agents = true means skills are NOT installed from the dep.
753        let dir = TempDir::new().unwrap();
754        let dep_dir = TempDir::new().unwrap();
755
756        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
757        write_agent(dir.path(), "coder", &["ext-skill"]);
758        std::fs::write(
759            dir.path().join("mars.toml"),
760            format!(
761                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", only_agents = true }}\n",
762                toml_path(dep_dir.path())
763            ),
764        )
765        .unwrap();
766
767        let report = super::check_dir(dir.path()).unwrap();
768        assert!(
769            !report.errors.is_empty(),
770            "only_agents filter must make skills unavailable — expected error: {:?}",
771            report.errors
772        );
773    }
774
775    // ── Fix 2: Local config leakage — local-dependencies must not satisfy refs ────
776
777    #[test]
778    fn check_local_dependency_skill_does_not_satisfy_ref() {
779        // Skills from [local-dependencies] are dev-only and must not satisfy
780        // skill references in the publish gate check.
781        let dir = TempDir::new().unwrap();
782        let local_dep_dir = TempDir::new().unwrap();
783
784        write_dep_package(local_dep_dir.path(), "local-dep", "0.1.0", &["local-skill"]);
785        write_agent(dir.path(), "coder", &["local-skill"]);
786        // [package] + [local-dependencies] only, no [dependencies]
787        std::fs::write(
788            dir.path().join("mars.toml"),
789            format!(
790                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n\n[local-dependencies]\nlocal-dep = {{ path = \"{}\" }}\n",
791                toml_path(local_dep_dir.path())
792            ),
793        )
794        .unwrap();
795
796        // has_package_dependencies checks config.dependencies (not local_dependencies),
797        // so this will be false → falls through to local-only warning path.
798        // That's the correct behavior: local-only validation, external ref → warning.
799        let report = super::check_dir(dir.path()).unwrap();
800        // local-skill is not in the local package, so it should warn (not error)
801        // since we're in local-only mode (no [dependencies]).
802        let has_warning = report.warnings.iter().any(|w| w.contains("local-skill"));
803        assert!(
804            has_warning,
805            "local-skill from [local-dependencies] must not satisfy ref in publish gate — expected warning: {:?}",
806            report.warnings
807        );
808    }
809
810    #[test]
811    fn check_local_dep_skill_not_available_when_regular_dep_present() {
812        // Fix 2 code path: [dependencies] is non-empty (triggers resolve_available_skills),
813        // skill is only in [local-dependencies]. Before the fix, local-deps were included
814        // in the resolved graph and could silently satisfy refs. After the fix, they are
815        // stripped and the missing skill is correctly flagged as an error.
816        let dir = TempDir::new().unwrap();
817        let regular_dep_dir = TempDir::new().unwrap();
818        let local_dep_dir = TempDir::new().unwrap();
819
820        // Regular dep provides an unrelated skill — exists only to satisfy has_package_dependencies.
821        write_dep_package(
822            regular_dep_dir.path(),
823            "regular-dep",
824            "0.1.0",
825            &["unrelated-skill"],
826        );
827        // Local dep has the skill the agent references.
828        write_dep_package(
829            local_dep_dir.path(),
830            "local-dep",
831            "0.1.0",
832            &["local-only-skill"],
833        );
834        write_agent(dir.path(), "coder", &["local-only-skill"]);
835        std::fs::write(
836            dir.path().join("mars.toml"),
837            format!(
838                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\nregular = {{ path = \"{}\" }}\n\n[local-dependencies]\nlocal = {{ path = \"{}\" }}\n",
839                toml_path(regular_dep_dir.path()),
840                toml_path(local_dep_dir.path())
841            ),
842        )
843        .unwrap();
844
845        let report = super::check_dir(dir.path()).unwrap();
846        assert!(
847            !report.errors.is_empty(),
848            "skill from [local-dependencies] must not satisfy ref in publish gate — expected error: {:?}",
849            report.errors
850        );
851        let joined = report.errors.join("\n");
852        assert!(
853            joined.contains("local-only-skill"),
854            "error must name the missing skill: {joined}"
855        );
856    }
857
858    #[test]
859    fn check_invalid_config_reports_error_instead_of_falling_back_to_local_only() {
860        let dir = TempDir::new().unwrap();
861        write_agent(dir.path(), "coder", &["missing-skill"]);
862        // Intentionally invalid TOML (Windows-style path escapes in basic string).
863        std::fs::write(
864            dir.path().join("mars.toml"),
865            "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"C:\\Users\\dev\\dep\" }\n",
866        )
867        .unwrap();
868
869        let report = super::check_dir(dir.path()).unwrap();
870        let joined = report.errors.join("\n");
871        assert!(
872            joined.contains("failed to load mars.toml for dependency checks"),
873            "expected config parse/load error to surface: {joined}"
874        );
875        let has_local_warning = report
876            .warnings
877            .iter()
878            .any(|w| w.contains("external dependency: `missing-skill`"));
879        assert!(
880            !has_local_warning,
881            "must not silently fall back to local-only warnings on invalid config: {:?}",
882            report.warnings
883        );
884    }
885}