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 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 crate::lock::ItemKind::Hook
239 | crate::lock::ItemKind::McpServer
240 | crate::lock::ItemKind::BootstrapDoc => {}
241 }
242 }
243
244 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 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 let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
274
275 match has_package_dependencies(base) {
276 Ok(true) => {
277 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 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 Ok(CheckReport {
322 agents: agent_count,
323 skills: skill_count,
324 errors,
325 warnings,
326 })
327}
328
329fn 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
342fn 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 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(); 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
399fn 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; };
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 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 #[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 #[test]
559 fn check_no_dependencies_warns_for_external_skill() {
560 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 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 #[test]
603 fn check_with_unresolvable_dep_fails_closed_with_remediation_hint() {
604 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]\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 #[test]
632 fn check_missing_skill_in_resolved_graph_is_error() {
633 let dir = TempDir::new().unwrap();
636 let dep_dir = TempDir::new().unwrap();
637
638 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 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 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 #[test]
687 fn check_skill_provided_by_path_dep_passes() {
688 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 #[test]
714 fn check_excluded_skill_in_dep_is_not_available() {
715 let dir = TempDir::new().unwrap();
718 let dep_dir = TempDir::new().unwrap();
719
720 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 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 #[test]
778 fn check_local_dependency_skill_does_not_satisfy_ref() {
779 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 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 let report = super::check_dir(dir.path()).unwrap();
800 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 let dir = TempDir::new().unwrap();
817 let regular_dep_dir = TempDir::new().unwrap();
818 let local_dep_dir = TempDir::new().unwrap();
819
820 write_dep_package(
822 regular_dep_dir.path(),
823 "regular-dep",
824 "0.1.0",
825 &["unrelated-skill"],
826 );
827 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 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}