1use 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#[derive(Debug, clap::Args)]
21pub struct CheckArgs {
22 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
34pub 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 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 let mut agent_diags = Vec::new();
130 let _profile =
131 crate::compiler::agents::parse_agent_profile(&fm, &mut agent_diags);
132 for diagnostic in agent_diags {
133 let message = format!("agent `{name}`: {}", diagnostic.message());
134 if diagnostic.is_error() {
135 errors.push(message);
136 } else {
137 warnings.push(message);
138 }
139 }
140
141 if fm.name().is_none() {
142 warnings.push(format!(
143 "agent `{filename}` has no `name` in frontmatter"
144 ));
145 }
146
147 if fm.get("description").and_then(|v| v.as_str()).is_none() {
148 warnings.push(format!("agent `{name}` has no `description`"));
149 }
150
151 if fm.name().is_some() && name != filename {
152 warnings.push(format!(
153 "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
154 ));
155 }
156
157 if let Some(existing) = agent_names.get(&name) {
158 errors.push(format!(
159 "duplicate agent name `{name}` in {} and {}",
160 existing.display(),
161 path.display()
162 ));
163 } else {
164 agent_names.insert(name.clone(), path.clone());
165 }
166
167 let skills = fm.skills();
168 if !skills.is_empty() {
169 agent_skill_refs.push((name, skills));
170 }
171 }
172 Err(e) => {
173 errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
174 }
175 },
176 Err(e) => {
177 errors.push(format!("cannot read {}: {e}", path.display()));
178 }
179 }
180 }
181 crate::lock::ItemKind::Skill => {
182 let (dirname, skill_md, duplicate_path) = if item.source_path
183 == std::path::Path::new(".")
184 {
185 let dirname = item.id.name.to_string();
186 (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
187 } else {
188 if super::is_symlink(&path) {
189 let name = path
190 .file_name()
191 .and_then(|n| n.to_str())
192 .unwrap_or_default();
193 warnings.push(format!(
194 "skipping symlinked skill `{name}` — source packages should not contain symlinks"
195 ));
196 continue;
197 }
198 let dirname = path
199 .file_name()
200 .and_then(|n| n.to_str())
201 .unwrap_or_default()
202 .to_string();
203 (dirname, path.join("SKILL.md"), path.clone())
204 };
205
206 match std::fs::read_to_string(&skill_md) {
207 Ok(content) => match frontmatter::parse(&content) {
208 Ok(fm) => {
209 let name = fm
210 .name()
211 .map(str::to_string)
212 .unwrap_or_else(|| dirname.clone());
213
214 if fm.name().is_none() {
215 warnings.push(format!(
216 "skill `{dirname}` has no `name` in frontmatter"
217 ));
218 }
219
220 if fm.get("description").and_then(|v| v.as_str()).is_none() {
221 warnings.push(format!("skill `{name}` has no `description`"));
222 }
223
224 if fm.name().is_some() && name != dirname {
225 warnings.push(format!(
226 "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
227 ));
228 }
229
230 if let Some(existing) = skill_names.get(&name) {
231 errors.push(format!(
232 "duplicate skill name `{name}` in {} and {}",
233 existing.display(),
234 duplicate_path.display()
235 ));
236 } else {
237 skill_names.insert(name, duplicate_path);
238 }
239 }
240 Err(e) => {
241 errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
242 }
243 },
244 Err(e) => {
245 errors.push(format!("cannot read {}: {e}", skill_md.display()));
246 }
247 }
248 }
249 crate::lock::ItemKind::Hook
251 | crate::lock::ItemKind::McpServer
252 | crate::lock::ItemKind::BootstrapDoc => {}
253 }
254 }
255
256 if skills_dir.is_dir() {
259 let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
260 .filter_map(|e| e.ok())
261 .filter(|e| e.path().is_dir())
262 .collect();
263 entries.sort_by_key(|e| e.file_name());
264 for entry in entries {
265 let path = entry.path();
266 let dirname = path
267 .file_name()
268 .and_then(|n| n.to_str())
269 .unwrap_or_default();
270 if !path.join("SKILL.md").exists() {
271 errors.push(format!("skill `{dirname}` is missing SKILL.md"));
272 }
273 }
274 }
275
276 let agent_count = agent_names.len();
277 let skill_count = skill_names.len();
278
279 if agent_count == 0 && skill_count == 0 {
281 errors.push("no agents or skills found — is this a mars source package?".to_string());
282 }
283
284 let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
286
287 match has_package_dependencies(base) {
288 Ok(true) => {
289 match resolve_available_skills(base) {
292 Ok(graph_skills) => {
293 for (agent_name, skills) in &agent_skill_refs {
294 for skill in skills {
295 if !available.contains(skill.as_str())
296 && !graph_skills.contains_key(skill)
297 {
298 errors.push(format!(
299 "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",
300 format_searched_packages(&graph_skills)
301 ));
302 }
303 }
304 }
305 }
306 Err(resolve_err) => {
307 errors.push(format!(
308 "dependency graph resolution failed: {resolve_err}\n hint: check network access, or use `mars version --force` to bypass the publish gate"
309 ));
310 }
311 }
312 }
313 Ok(false) => {
314 for (agent_name, skills) in &agent_skill_refs {
316 for skill in skills {
317 if !available.contains(skill.as_str()) {
318 warnings.push(format!(
319 "external dependency: `{skill}` (referenced by: {agent_name})"
320 ));
321 }
322 }
323 }
324 }
325 Err(config_err) => {
326 errors.push(format!(
327 "failed to load mars.toml for dependency checks: {config_err}\n hint: fix mars.toml syntax (Windows paths in TOML must use `/` or escaped `\\\\`)"
328 ));
329 }
330 }
331
332 Ok(CheckReport {
334 agents: agent_count,
335 skills: skill_count,
336 errors,
337 warnings,
338 })
339}
340
341fn has_package_dependencies(base: &Path) -> Result<bool, MarsError> {
347 match crate::config::load(base) {
348 Ok(config) => Ok(config.package.is_some() && !config.dependencies.is_empty()),
349 Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(false),
350 Err(err) => Err(err),
351 }
352}
353
354fn resolve_available_skills(base: &Path) -> Result<HashMap<String, (String, String)>, MarsError> {
363 use crate::resolve::{ResolveOptions, resolve};
364 use crate::source::GlobalCache;
365 use crate::sync::provider::RealSourceProvider;
366
367 let config = crate::config::load(base)?;
368 let mut publish_config = config.clone();
372 publish_config.local_dependencies.clear();
373 let effective = crate::config::merge(publish_config, crate::config::LocalConfig::default())?;
374
375 let cache = GlobalCache::new()?;
376 let provider = RealSourceProvider {
377 cache: &cache,
378 project_root: base,
379 };
380 let mut diag = crate::diagnostic::DiagnosticCollector::new();
381 let options = ResolveOptions::default(); let graph = resolve(&effective, &provider, None, &options, &mut diag)?;
384
385 let mut skills: HashMap<String, (String, String)> = HashMap::new();
386 for (source_name, node) in &graph.nodes {
387 let discovered =
388 crate::discover::discover_resolved_source(&node.rooted_ref.package_root, None)?;
389 let package_filters = graph.filters.get(source_name);
390 for item in &discovered {
391 if item.id.kind == crate::lock::ItemKind::Skill
392 && item_passes_filters(item, package_filters)
393 {
394 let version_str = node
395 .resolved_ref
396 .version
397 .as_ref()
398 .map(|v| v.to_string())
399 .unwrap_or_else(|| "unknown".to_string());
400 skills.insert(
401 item.id.name.to_string(),
402 (source_name.to_string(), version_str),
403 );
404 }
405 }
406 }
407
408 Ok(skills)
409}
410
411fn item_passes_filters(
420 item: &crate::discover::DiscoveredItem,
421 filters: Option<&Vec<crate::config::FilterMode>>,
422) -> bool {
423 let Some(filters) = filters else {
424 return true; };
426 filters.iter().any(|filter| match filter {
427 crate::config::FilterMode::All => true,
428 crate::config::FilterMode::Include { skills, .. } => skills.contains(&item.id.name),
429 crate::config::FilterMode::Exclude(excluded) => {
430 let source_path = item.source_path.to_string_lossy();
431 !excluded.iter().any(|e| {
432 *e == item.id.name || crate::target::paths_equivalent(e.as_ref(), &source_path)
433 })
434 }
435 crate::config::FilterMode::OnlySkills => true,
436 crate::config::FilterMode::OnlyAgents => false,
437 })
438}
439
440fn format_searched_packages(graph_skills: &HashMap<String, (String, String)>) -> String {
441 let mut packages: Vec<(&str, &str)> = graph_skills
442 .values()
443 .map(|(name, ver)| (name.as_str(), ver.as_str()))
444 .collect();
445 packages.sort();
446 packages.dedup();
447 if packages.is_empty() {
448 "no dependency packages resolved".to_string()
449 } else {
450 packages
451 .iter()
452 .map(|(name, ver)| format!("{name}@{ver}"))
453 .collect::<Vec<_>>()
454 .join(", ")
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use std::path::Path;
461
462 use tempfile::TempDir;
463
464 fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
465 let agents = path.join("agents");
466 std::fs::create_dir_all(&agents).unwrap();
467 let skills_str = skills.join(", ");
468 std::fs::write(
469 agents.join(format!("{filename}.md")),
470 format!(
471 "---\nname: {filename}\ndescription: test agent\nskills: [{skills_str}]\n---\n# Agent"
472 ),
473 )
474 .unwrap();
475 }
476
477 fn write_agent_content(path: &Path, filename: &str, content: &str) {
478 let agents = path.join("agents");
479 std::fs::create_dir_all(&agents).unwrap();
480 std::fs::write(agents.join(format!("{filename}.md")), content).unwrap();
481 }
482
483 fn write_dep_package(path: &Path, name: &str, version: &str, skills: &[&str]) {
485 std::fs::create_dir_all(path).unwrap();
486 std::fs::write(
487 path.join("mars.toml"),
488 format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n\n[dependencies]\n"),
489 )
490 .unwrap();
491 for skill_name in skills {
492 let skill_dir = path.join("skills").join(skill_name);
493 std::fs::create_dir_all(&skill_dir).unwrap();
494 std::fs::write(
495 skill_dir.join("SKILL.md"),
496 format!("---\nname: {skill_name}\ndescription: test skill\n---\n# Skill"),
497 )
498 .unwrap();
499 }
500 }
501
502 fn toml_path(path: &Path) -> String {
503 path.to_string_lossy().replace('\\', "/")
504 }
505
506 #[cfg(unix)]
509 #[test]
510 fn check_skips_symlinked_agent() {
511 let dir = TempDir::new().unwrap();
512 let agents = dir.path().join("agents");
513 std::fs::create_dir_all(&agents).unwrap();
514
515 std::fs::write(
516 agents.join("real.md"),
517 "---\nname: real\ndescription: real agent\n---\n# Real",
518 )
519 .unwrap();
520 std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
521
522 let args = super::CheckArgs {
523 path: Some(dir.path().to_path_buf()),
524 };
525 let code = super::run(&args, true).unwrap();
526 assert_eq!(code, 0);
527 }
528
529 #[cfg(unix)]
530 #[test]
531 fn check_skips_symlinked_skill() {
532 let dir = TempDir::new().unwrap();
533 let skills = dir.path().join("skills");
534 let real_skill = skills.join("real-skill");
535 std::fs::create_dir_all(&real_skill).unwrap();
536 std::fs::write(
537 real_skill.join("SKILL.md"),
538 "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
539 )
540 .unwrap();
541 std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
542
543 let agents = dir.path().join("agents");
544 std::fs::create_dir_all(&agents).unwrap();
545 std::fs::write(
546 agents.join("coder.md"),
547 "---\nname: coder\ndescription: agent\n---\n# Coder",
548 )
549 .unwrap();
550
551 let args = super::CheckArgs {
552 path: Some(dir.path().to_path_buf()),
553 };
554 let code = super::run(&args, true).unwrap();
555 assert_eq!(code, 0);
556 }
557
558 #[test]
559 fn check_accepts_flat_skill_repo() {
560 let dir = TempDir::new().unwrap();
561 std::fs::write(
562 dir.path().join("SKILL.md"),
563 "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
564 )
565 .unwrap();
566
567 let args = super::CheckArgs {
568 path: Some(dir.path().to_path_buf()),
569 };
570 let code = super::run(&args, true).unwrap();
571 assert_eq!(code, 0);
572 }
573
574 #[test]
577 fn check_no_dependencies_warns_for_external_skill() {
578 let dir = TempDir::new().unwrap();
580 write_agent(dir.path(), "coder", &["missing-skill"]);
581
582 let report = super::check_dir(dir.path()).unwrap();
583 assert!(
584 report.errors.is_empty(),
585 "expected no errors in local-only mode: {:?}",
586 report.errors
587 );
588 let has_warning = report
589 .warnings
590 .iter()
591 .any(|w| w.contains("external dependency: `missing-skill`"));
592 assert!(
593 has_warning,
594 "expected warning for missing-skill: {:?}",
595 report.warnings
596 );
597 }
598
599 #[test]
600 fn check_warns_for_truly_missing_external_skill() {
601 let dir = TempDir::new().unwrap();
603 write_agent(dir.path(), "coder", &["missing-skill"]);
604
605 let report = super::check_dir(dir.path()).unwrap();
606 let has_missing_warning = report
607 .warnings
608 .iter()
609 .any(|w| w.contains("external dependency: `missing-skill`"));
610
611 assert!(
612 has_missing_warning,
613 "expected missing external dependency warning, got: {:?}",
614 report.warnings
615 );
616 }
617
618 #[test]
619 fn check_errors_for_malformed_agent_model_policy() {
620 let dir = TempDir::new().unwrap();
621 write_agent_content(
622 dir.path(),
623 "browser-tester",
624 "---\nname: browser-tester\ndescription: browser test\nmodel-policies:\n - match:\n alias: gpt55\n model: gpt-5.5\n---\n# Browser Tester",
625 );
626
627 let report = super::check_dir(dir.path()).unwrap();
628
629 let joined = report.errors.join("\n");
630 assert!(
631 joined.contains("model-policies[1].match"),
632 "expected model-policies match error: {joined}"
633 );
634 }
635
636 #[test]
639 fn check_with_unresolvable_dep_fails_closed_with_remediation_hint() {
640 let dir = TempDir::new().unwrap();
644 write_agent(dir.path(), "coder", &["some-skill"]);
645 std::fs::write(
646 dir.path().join("mars.toml"),
647 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-mars-dep-xyz-abc\" }\n",
650 )
651 .unwrap();
652
653 let report = super::check_dir(dir.path()).unwrap();
654 assert!(
655 !report.errors.is_empty(),
656 "expected errors when dep cannot be resolved"
657 );
658 let joined = report.errors.join("\n");
659 assert!(
660 joined.contains("mars version --force"),
661 "error must include remediation hint: {joined}"
662 );
663 }
664
665 #[test]
668 fn check_missing_skill_in_resolved_graph_is_error() {
669 let dir = TempDir::new().unwrap();
672 let dep_dir = TempDir::new().unwrap();
673
674 write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["provided-skill"]);
676
677 write_agent(dir.path(), "coder", &["missing-skill"]);
678 std::fs::write(
679 dir.path().join("mars.toml"),
680 format!(
681 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
682 toml_path(dep_dir.path())
683 ),
684 )
685 .unwrap();
686
687 let report = super::check_dir(dir.path()).unwrap();
688 assert!(
689 !report.errors.is_empty(),
690 "expected error for missing skill, got: {:?}",
691 report.errors
692 );
693 let joined = report.errors.join("\n");
694 assert!(
696 joined.contains("coder"),
697 "error must name the agent: {joined}"
698 );
699 assert!(
700 joined.contains("missing-skill"),
701 "error must name the missing skill: {joined}"
702 );
703 assert!(
704 joined.contains("searched:"),
705 "error must list searched packages: {joined}"
706 );
707 assert!(
708 joined.contains("hint:"),
709 "error must include remediation guidance: {joined}"
710 );
711 let has_warning = report.warnings.iter().any(|w| w.contains("missing-skill"));
713 assert!(
714 !has_warning,
715 "missing skill must be error, not warning: {:?}",
716 report.warnings
717 );
718 }
719
720 #[test]
723 fn check_skill_provided_by_path_dep_passes() {
724 let dir = TempDir::new().unwrap();
726 let dep_dir = TempDir::new().unwrap();
727
728 write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
729 write_agent(dir.path(), "coder", &["ext-skill"]);
730 std::fs::write(
731 dir.path().join("mars.toml"),
732 format!(
733 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
734 toml_path(dep_dir.path())
735 ),
736 )
737 .unwrap();
738
739 let report = super::check_dir(dir.path()).unwrap();
740 assert!(
741 report.errors.is_empty(),
742 "expected no errors when skill is in dep: {:?}",
743 report.errors
744 );
745 }
746
747 #[test]
750 fn check_excluded_skill_in_dep_is_not_available() {
751 let dir = TempDir::new().unwrap();
754 let dep_dir = TempDir::new().unwrap();
755
756 write_dep_package(
758 dep_dir.path(),
759 "dep-pkg",
760 "0.1.0",
761 &["ext-skill", "other-skill"],
762 );
763 write_agent(dir.path(), "coder", &["ext-skill"]);
764 std::fs::write(
765 dir.path().join("mars.toml"),
766 format!(
767 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", exclude = [\"ext-skill\"] }}\n",
768 toml_path(dep_dir.path())
769 ),
770 )
771 .unwrap();
772
773 let report = super::check_dir(dir.path()).unwrap();
774 assert!(
775 !report.errors.is_empty(),
776 "excluded skill must not satisfy ref — expected error, got none: {:?}",
777 report.errors
778 );
779 let joined = report.errors.join("\n");
780 assert!(
781 joined.contains("ext-skill"),
782 "error must mention the missing skill: {joined}"
783 );
784 }
785
786 #[test]
787 fn check_only_agents_filter_makes_skills_unavailable() {
788 let dir = TempDir::new().unwrap();
790 let dep_dir = TempDir::new().unwrap();
791
792 write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
793 write_agent(dir.path(), "coder", &["ext-skill"]);
794 std::fs::write(
795 dir.path().join("mars.toml"),
796 format!(
797 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", only_agents = true }}\n",
798 toml_path(dep_dir.path())
799 ),
800 )
801 .unwrap();
802
803 let report = super::check_dir(dir.path()).unwrap();
804 assert!(
805 !report.errors.is_empty(),
806 "only_agents filter must make skills unavailable — expected error: {:?}",
807 report.errors
808 );
809 }
810
811 #[test]
814 fn check_local_dependency_skill_does_not_satisfy_ref() {
815 let dir = TempDir::new().unwrap();
818 let local_dep_dir = TempDir::new().unwrap();
819
820 write_dep_package(local_dep_dir.path(), "local-dep", "0.1.0", &["local-skill"]);
821 write_agent(dir.path(), "coder", &["local-skill"]);
822 std::fs::write(
824 dir.path().join("mars.toml"),
825 format!(
826 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n\n[local-dependencies]\nlocal-dep = {{ path = \"{}\" }}\n",
827 toml_path(local_dep_dir.path())
828 ),
829 )
830 .unwrap();
831
832 let report = super::check_dir(dir.path()).unwrap();
836 let has_warning = report.warnings.iter().any(|w| w.contains("local-skill"));
839 assert!(
840 has_warning,
841 "local-skill from [local-dependencies] must not satisfy ref in publish gate — expected warning: {:?}",
842 report.warnings
843 );
844 }
845
846 #[test]
847 fn check_local_dep_skill_not_available_when_regular_dep_present() {
848 let dir = TempDir::new().unwrap();
853 let regular_dep_dir = TempDir::new().unwrap();
854 let local_dep_dir = TempDir::new().unwrap();
855
856 write_dep_package(
858 regular_dep_dir.path(),
859 "regular-dep",
860 "0.1.0",
861 &["unrelated-skill"],
862 );
863 write_dep_package(
865 local_dep_dir.path(),
866 "local-dep",
867 "0.1.0",
868 &["local-only-skill"],
869 );
870 write_agent(dir.path(), "coder", &["local-only-skill"]);
871 std::fs::write(
872 dir.path().join("mars.toml"),
873 format!(
874 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\nregular = {{ path = \"{}\" }}\n\n[local-dependencies]\nlocal = {{ path = \"{}\" }}\n",
875 toml_path(regular_dep_dir.path()),
876 toml_path(local_dep_dir.path())
877 ),
878 )
879 .unwrap();
880
881 let report = super::check_dir(dir.path()).unwrap();
882 assert!(
883 !report.errors.is_empty(),
884 "skill from [local-dependencies] must not satisfy ref in publish gate — expected error: {:?}",
885 report.errors
886 );
887 let joined = report.errors.join("\n");
888 assert!(
889 joined.contains("local-only-skill"),
890 "error must name the missing skill: {joined}"
891 );
892 }
893
894 #[test]
895 fn check_invalid_config_reports_error_instead_of_falling_back_to_local_only() {
896 let dir = TempDir::new().unwrap();
897 write_agent(dir.path(), "coder", &["missing-skill"]);
898 std::fs::write(
900 dir.path().join("mars.toml"),
901 "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"C:\\Users\\dev\\dep\" }\n",
902 )
903 .unwrap();
904
905 let report = super::check_dir(dir.path()).unwrap();
906 let joined = report.errors.join("\n");
907 assert!(
908 joined.contains("failed to load mars.toml for dependency checks"),
909 "expected config parse/load error to surface: {joined}"
910 );
911 let has_local_warning = report
912 .warnings
913 .iter()
914 .any(|w| w.contains("external dependency: `missing-skill`"));
915 assert!(
916 !has_local_warning,
917 "must not silently fall back to local-only warnings on invalid config: {:?}",
918 report.warnings
919 );
920 }
921}