1use std::collections::{BTreeMap, BTreeSet};
46use std::path::{Path, PathBuf};
47
48use schemars::JsonSchema;
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51
52use crate::discovery::glob::Glob;
53use crate::evidence::{Ledger, ProofState, TestId};
54use crate::gates::model::GateModel;
55use crate::index::Index;
56use crate::model::Severity;
57
58pub const SKILL: &str = "skill";
60
61pub const TEST_MARKER: &str = "majordomus-skill:";
65
66pub const SKILL_URI_PREFIX: &str = "majordomus://skill/";
68
69pub const SKILL_PAGES_DIR: &str = "site/content/skills";
71
72pub const SKILLS_VERIFY_COMMAND: &str = "skills verify";
75
76pub const SKILLS_VALIDATOR: &str = "skills";
78
79const TEST_DIRS: &[&str] = &["test/cases", "apps/majordomus-cli/tests"];
82
83const ROOT_SURFACES: &[&str] = &[
87 "justfile",
88 ".just/**",
89 ".github/workflows/**",
90 "AGENTS.md",
91 "CLAUDE.md",
92 "GEMINI.md",
93];
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111pub struct SkillProvenance {
112 pub origin: String,
114 pub ledger: String,
116 pub decision: String,
118}
119
120#[derive(
131 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
132)]
133#[serde(rename_all = "snake_case")]
134pub enum SkillEvidence {
135 Proven,
137 InputsUnchanged,
139 Stale,
141 Failing,
143 NotRun,
145 Unrunnable,
147 Untested,
149}
150
151impl SkillEvidence {
152 pub fn current(self) -> bool {
159 matches!(self, SkillEvidence::Proven | SkillEvidence::InputsUnchanged)
160 }
161
162 pub fn label(self) -> &'static str {
170 match self {
171 SkillEvidence::Proven => "proven",
172 SkillEvidence::InputsUnchanged => "inputs_unchanged",
173 SkillEvidence::Stale => "stale",
174 SkillEvidence::Failing => "failing",
175 SkillEvidence::NotRun => "not_run",
176 SkillEvidence::Unrunnable => "unrunnable",
177 SkillEvidence::Untested => "untested",
178 }
179 }
180
181 pub fn of(state: ProofState) -> SkillEvidence {
189 match state {
190 ProofState::Proven => SkillEvidence::Proven,
191 ProofState::InputsUnchanged => SkillEvidence::InputsUnchanged,
192 ProofState::Stale => SkillEvidence::Stale,
193 ProofState::Failing => SkillEvidence::Failing,
194 ProofState::NotRun => SkillEvidence::NotRun,
195 ProofState::Unrunnable | ProofState::NoTest => SkillEvidence::Unrunnable,
196 }
197 }
198}
199
200#[derive(
207 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
208)]
209#[serde(rename_all = "snake_case")]
210pub enum SkillStanding {
211 Proven,
213 Partial,
215 Orphan,
217 Invalid,
220 NotRequired,
222}
223
224impl SkillStanding {
225 pub fn label(self) -> &'static str {
233 match self {
234 SkillStanding::Proven => "proven",
235 SkillStanding::Partial => "partial",
236 SkillStanding::Orphan => "orphan",
237 SkillStanding::Invalid => "invalid",
238 SkillStanding::NotRequired => "not_required",
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
250#[serde(rename_all = "snake_case")]
251pub enum SkillFindingLevel {
252 Fail,
254 Warn,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273pub struct SkillFinding {
274 pub level: SkillFindingLevel,
276 pub code: String,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub skill: Option<String>,
282 pub subject: String,
284 pub message: String,
286 pub reproduce: String,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct SkillTest {
306 pub path: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub test: Option<String>,
311 pub state: ProofState,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub reproduce: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub recorded_at: Option<String>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
329pub struct SkillTested {
330 pub state: SkillEvidence,
332 pub tests: Vec<SkillTest>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
344pub struct SkillDocumented {
345 pub documented: bool,
347 pub page: String,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
360pub struct SkillEnforced {
361 pub enforced: bool,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub doctrine: Option<String>,
366 pub gates: Vec<String>,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct SkillInvocation {
383 pub path: String,
385 pub line: usize,
387 pub reference: String,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399pub struct SkillUsed {
400 pub used: bool,
402 pub invocations: Vec<SkillInvocation>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420pub struct SkillStatus {
421 pub id: String,
423 pub uri: String,
425 pub title: String,
427 pub description: String,
429 pub status: String,
432 pub version: u64,
434 pub path: String,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub provenance: Option<SkillProvenance>,
439 pub valid: bool,
441 pub tested: SkillTested,
443 pub documented: SkillDocumented,
445 pub enforced: SkillEnforced,
447 pub used: SkillUsed,
449 pub standing: SkillStanding,
451 pub findings: Vec<SkillFinding>,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466pub struct Skills {
467 pub skills: Vec<SkillStatus>,
469 pub findings: Vec<SkillFinding>,
471}
472
473impl Skills {
474 pub fn build(index: &Index, ledger: &Ledger) -> Skills {
487 let root = PathBuf::from(&index.repository.root);
488 let skills_dir = index
489 .repository
490 .sections
491 .get("skills")
492 .cloned()
493 .unwrap_or_else(|| ".ai/repo/skills".to_string());
494 let tracked: Vec<String> = crate::git::ls_files_all(&root).unwrap_or_default();
495 let tracked_set: BTreeSet<&str> = tracked.iter().map(String::as_str).collect();
496
497 let objects: Vec<&crate::model::Object> =
498 index.objects.iter().filter(|o| o.kind == SKILL).collect();
499 let ids: BTreeSet<String> = objects.iter().map(|o| skill_id(o)).collect();
500
501 let markers = test_markers(&root, &tracked);
502 let invocations = invocation_references(&root, &tracked, index, &skills_dir);
503 let comparisons = crate::rules::ledger_comparisons(&root, ledger);
504 let doctrine = crate::rules::definitions(index)
505 .into_iter()
506 .find(|d| d.enforcement.validator.as_deref() == Some(SKILLS_VALIDATOR))
507 .map(|d| d.id);
508 let model = GateModel::load(&root).ok();
509
510 let mut findings = Vec::new();
511 let mut skills = Vec::new();
512 for o in objects {
513 let id = skill_id(o);
514 let path = o.provenance.path.clone();
515 let meta = &o.metadata;
516 let status = str_field(meta, "status");
517 let active = status == "active";
518 let contract: Vec<&crate::model::Diagnostic> = index
519 .diagnostics
520 .iter()
521 .filter(|d| d.severity == Severity::Error && d.path.as_deref() == Some(&path))
522 .collect();
523 let mut own = Vec::new();
524 let explain = format!("majordomus skills explain {id}");
525 for d in &contract {
526 own.push(SkillFinding {
527 level: SkillFindingLevel::Fail,
528 code: "contract".into(),
529 skill: Some(id.clone()),
530 subject: path.clone(),
531 message: format!("{}: {}", d.code, d.message),
532 reproduce: "majordomus skills check".into(),
533 });
534 }
535
536 let mut tests = Vec::new();
538 for (test_path, named) in &markers {
539 if !named.contains(&id) {
540 continue;
541 }
542 let tid = TestId::of(test_path);
543 let execution = tid.as_ref().and_then(|t| ledger.latest(&t.as_string()));
544 let state =
545 crate::rules::test_state(&root, &comparisons, tid.as_ref(), execution, &path);
546 tests.push(SkillTest {
547 path: test_path.clone(),
548 test: tid.as_ref().map(TestId::as_string),
549 state,
550 reproduce: tid.as_ref().map(TestId::reproduce),
551 recorded_at: execution.map(|e| e.at.clone()),
552 });
553 }
554 let evidence = tests
555 .iter()
556 .map(|t| SkillEvidence::of(t.state))
557 .max()
558 .unwrap_or(SkillEvidence::Untested);
559 if active {
560 match evidence {
561 SkillEvidence::Untested => own.push(SkillFinding {
562 level: SkillFindingLevel::Fail,
563 code: "untested".into(),
564 skill: Some(id.clone()),
565 subject: path.clone(),
566 message: format!(
567 "orphan: no test names skill '{id}'; add `{TEST_MARKER} {id}` on a \
568 comment line of the case or crate test that exercises it"
569 ),
570 reproduce: explain.clone(),
571 }),
572 SkillEvidence::Failing => own.push(SkillFinding {
573 level: SkillFindingLevel::Fail,
574 code: "failing".into(),
575 skill: Some(id.clone()),
576 subject: path.clone(),
577 message: format!(
578 "the latest recorded run of a test naming skill '{id}' did not pass"
579 ),
580 reproduce: weakest_reproduce(&tests, &explain),
581 }),
582 e if !e.current() => own.push(SkillFinding {
583 level: SkillFindingLevel::Warn,
584 code: "unevidenced".into(),
585 skill: Some(id.clone()),
586 subject: path.clone(),
587 message: format!(
588 "skill '{id}' is named by {} test(s) and its evidence is {}: run the \
589 test and record it (majordomus evidence record)",
590 tests.len(),
591 e.label()
592 ),
593 reproduce: weakest_reproduce(&tests, &explain),
594 }),
595 _ => {}
596 }
597 }
598
599 let page = format!("{SKILL_PAGES_DIR}/{id}.md");
601 let documented = tracked_set.contains(page.as_str());
602 if active && !documented {
603 own.push(SkillFinding {
604 level: SkillFindingLevel::Warn,
605 code: "undocumented".into(),
606 skill: Some(id.clone()),
607 subject: path.clone(),
608 message: format!("skill '{id}' has no tracked page at {page}"),
609 reproduce: "scripts/generate-site-data".into(),
610 });
611 }
612
613 let gates = verifying_gates(model.as_ref(), &path);
615 let enforced = doctrine.is_some() && !gates.is_empty();
616 if active && !enforced {
617 own.push(SkillFinding {
618 level: SkillFindingLevel::Warn,
619 code: "unenforced".into(),
620 skill: Some(id.clone()),
621 subject: path.clone(),
622 message: match &doctrine {
623 None => format!(
624 "skill '{id}': no dispatched rule validates skills (validator \
625 `{SKILLS_VALIDATOR}`)"
626 ),
627 Some(_) => format!(
628 "skill '{id}': no CI gate selected by a change to {path} runs \
629 `{SKILLS_VERIFY_COMMAND}`"
630 ),
631 },
632 reproduce: "scripts/ci-plan".into(),
633 });
634 }
635
636 let used: Vec<SkillInvocation> = invocations
638 .iter()
639 .filter(|(target, _)| target == &id)
640 .map(|(_, inv)| inv.clone())
641 .collect();
642 if active && used.is_empty() {
643 own.push(SkillFinding {
644 level: SkillFindingLevel::Fail,
645 code: "unused".into(),
646 skill: Some(id.clone()),
647 subject: path.clone(),
648 message: format!(
649 "orphan: nothing invokes skill '{id}'; reference `{SKILL_URI_PREFIX}{id}` \
650 from a workflow, prompt, profile, provider template, recipe or CI \
651 workflow that should follow it"
652 ),
653 reproduce: explain.clone(),
654 });
655 }
656
657 let standing = if !contract.is_empty() {
658 SkillStanding::Invalid
659 } else if !active {
660 SkillStanding::NotRequired
661 } else if evidence == SkillEvidence::Untested || used.is_empty() {
662 SkillStanding::Orphan
663 } else if evidence.current() && documented && enforced {
664 SkillStanding::Proven
665 } else {
666 SkillStanding::Partial
667 };
668
669 findings.extend(own.iter().cloned());
670 skills.push(SkillStatus {
671 uri: format!("{SKILL_URI_PREFIX}{id}"),
672 title: o.title.clone().unwrap_or_else(|| id.clone()),
673 description: o.description.clone().unwrap_or_default(),
674 status,
675 version: meta.get("version").and_then(Value::as_u64).unwrap_or(0),
676 provenance: provenance_of(meta),
677 valid: contract.is_empty(),
678 tested: SkillTested {
679 state: evidence,
680 tests,
681 },
682 documented: SkillDocumented { documented, page },
683 enforced: SkillEnforced {
684 enforced,
685 doctrine: doctrine.clone(),
686 gates,
687 },
688 used: SkillUsed {
689 used: !used.is_empty(),
690 invocations: used,
691 },
692 standing,
693 findings: own,
694 path,
695 id,
696 });
697 }
698
699 let indexed: BTreeSet<&str> = skills.iter().map(|s| s.path.as_str()).collect();
702 let prefix = format!("{}/", skills_dir.trim_end_matches('/'));
703 let mut refused: BTreeMap<String, Vec<&crate::model::Diagnostic>> = BTreeMap::new();
704 for d in index
705 .diagnostics
706 .iter()
707 .filter(|d| d.severity == Severity::Error)
708 {
709 let Some(p) = d.path.as_deref() else { continue };
710 let Some(rest) = p.strip_prefix(&prefix) else {
711 continue;
712 };
713 if rest.ends_with("/SKILL.md") && rest.matches('/').count() == 1 && !indexed.contains(p)
714 {
715 refused.entry(p.to_string()).or_default().push(d);
716 }
717 }
718 for (path, diagnostics) in refused {
719 let id = path
720 .trim_start_matches(&prefix)
721 .trim_end_matches("/SKILL.md")
722 .to_string();
723 let own: Vec<SkillFinding> = diagnostics
724 .iter()
725 .map(|d| SkillFinding {
726 level: SkillFindingLevel::Fail,
727 code: "contract".into(),
728 skill: Some(id.clone()),
729 subject: path.clone(),
730 message: format!("{}: {}", d.code, d.message),
731 reproduce: "majordomus skills check".into(),
732 })
733 .collect();
734 findings.extend(own.iter().cloned());
735 skills.push(SkillStatus {
736 uri: format!("{SKILL_URI_PREFIX}{id}"),
737 title: id.clone(),
738 description: String::new(),
739 status: "unknown".into(),
740 version: 0,
741 provenance: None,
742 valid: false,
743 tested: SkillTested {
744 state: SkillEvidence::Untested,
745 tests: Vec::new(),
746 },
747 documented: SkillDocumented {
748 documented: false,
749 page: format!("{SKILL_PAGES_DIR}/{id}.md"),
750 },
751 enforced: SkillEnforced {
752 enforced: false,
753 doctrine: doctrine.clone(),
754 gates: Vec::new(),
755 },
756 used: SkillUsed {
757 used: false,
758 invocations: Vec::new(),
759 },
760 standing: SkillStanding::Invalid,
761 findings: own,
762 path,
763 id,
764 });
765 }
766
767 for (test_path, named) in &markers {
769 for n in named.iter().filter(|n| !ids.contains(*n)) {
770 findings.push(SkillFinding {
771 level: SkillFindingLevel::Fail,
772 code: "unknown_skill_in_test".into(),
773 skill: None,
774 subject: test_path.clone(),
775 message: format!(
776 "the test names skill '{n}', which does not exist; a binding to nothing \
777 reads as proof"
778 ),
779 reproduce: "majordomus skills list".into(),
780 });
781 }
782 }
783 for (target, inv) in &invocations {
784 if !ids.contains(target) {
785 findings.push(SkillFinding {
786 level: SkillFindingLevel::Fail,
787 code: "unknown_skill_invoked".into(),
788 skill: None,
789 subject: inv.path.clone(),
790 message: format!(
791 "line {} invokes skill '{target}' ({}), which does not exist",
792 inv.line, inv.reference
793 ),
794 reproduce: "majordomus skills list".into(),
795 });
796 }
797 }
798
799 Skills { skills, findings }
800 }
801
802 pub fn skill(&self, id: &str) -> Option<&SkillStatus> {
812 self.skills.iter().find(|s| s.id == id)
813 }
814
815 pub fn failures(&self) -> usize {
823 self.findings
824 .iter()
825 .filter(|f| f.level == SkillFindingLevel::Fail)
826 .count()
827 }
828
829 pub fn warnings(&self) -> usize {
837 self.findings
838 .iter()
839 .filter(|f| f.level == SkillFindingLevel::Warn)
840 .count()
841 }
842}
843
844fn skill_id(o: &crate::model::Object) -> String {
847 let id = str_field(&o.metadata, "id");
848 if id.is_empty() {
849 o.identity.clone()
850 } else {
851 id
852 }
853}
854
855fn str_field(meta: &Value, key: &str) -> String {
856 meta.get(key)
857 .and_then(Value::as_str)
858 .unwrap_or_default()
859 .to_string()
860}
861
862fn provenance_of(meta: &Value) -> Option<SkillProvenance> {
863 let p = meta.get("provenance")?;
864 Some(SkillProvenance {
865 origin: str_field(p, "origin"),
866 ledger: str_field(p, "ledger"),
867 decision: str_field(p, "decision"),
868 })
869}
870
871fn weakest_reproduce(tests: &[SkillTest], otherwise: &str) -> String {
874 tests
875 .iter()
876 .max_by_key(|t| SkillEvidence::of(t.state))
877 .and_then(|t| t.reproduce.clone())
878 .unwrap_or_else(|| otherwise.to_string())
879}
880
881fn is_skill_id(word: &str) -> bool {
882 let mut chars = word.chars();
883 matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
884 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
885}
886
887pub fn marker_ids(line: &str) -> Vec<String> {
900 let t = line.trim_start();
901 let body = if let Some(r) = t.strip_prefix("//!") {
902 r
903 } else if let Some(r) = t.strip_prefix("//") {
904 r
905 } else if let Some(r) = t.strip_prefix('#') {
906 r
907 } else {
908 return Vec::new();
909 };
910 let Some(rest) = body.trim_start().strip_prefix(TEST_MARKER) else {
911 return Vec::new();
912 };
913 rest.split(|c: char| c.is_whitespace() || c == ',')
914 .filter(|w| is_skill_id(w))
915 .map(str::to_string)
916 .collect()
917}
918
919fn test_markers(root: &Path, tracked: &[String]) -> BTreeMap<String, BTreeSet<String>> {
921 let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
922 for path in tracked {
923 if !TEST_DIRS.iter().any(|d| path.starts_with(&format!("{d}/"))) {
924 continue;
925 }
926 if TestId::of(path).is_none() {
927 continue;
928 }
929 let Ok(text) = std::fs::read_to_string(root.join(path)) else {
930 continue;
931 };
932 let named: BTreeSet<String> = text.lines().flat_map(marker_ids).collect();
933 if !named.is_empty() {
934 out.insert(path.clone(), named);
935 }
936 }
937 out
938}
939
940pub fn invocation_surfaces(sections: &BTreeMap<String, String>, skills_dir: &str) -> Vec<String> {
954 let mut out: Vec<String> = ["workflows", "prompts", "profiles"]
955 .iter()
956 .filter_map(|k| sections.get(*k))
957 .map(|d| format!("{}/**", d.trim_end_matches('/')))
958 .collect();
959 if let Some((layer, _)) = skills_dir.trim_end_matches('/').rsplit_once('/') {
960 out.push(format!("{layer}/providers/**"));
961 }
962 out.extend(ROOT_SURFACES.iter().map(|s| s.to_string()));
963 out
964}
965
966fn invocation_references(
969 root: &Path,
970 tracked: &[String],
971 index: &Index,
972 skills_dir: &str,
973) -> Vec<(String, SkillInvocation)> {
974 let globs: Vec<Glob> = invocation_surfaces(&index.repository.sections, skills_dir)
975 .iter()
976 .map(|p| Glob::new(p))
977 .collect();
978 let path_prefix = format!("{}/", skills_dir.trim_end_matches('/'));
979 let mut out = Vec::new();
980 for path in tracked {
981 if !globs.iter().any(|g| g.matches(path)) {
982 continue;
983 }
984 let Ok(text) = std::fs::read_to_string(root.join(path)) else {
985 continue;
986 };
987 for (n, line) in text.lines().enumerate() {
988 for (target, reference) in references_in(line, &path_prefix) {
989 out.push((
990 target,
991 SkillInvocation {
992 path: path.clone(),
993 line: n + 1,
994 reference,
995 },
996 ));
997 }
998 }
999 }
1000 out
1001}
1002
1003pub fn references_in(line: &str, skills_prefix: &str) -> Vec<(String, String)> {
1016 let mut out = Vec::new();
1017 let id_end = |s: &str| {
1018 s.find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
1019 .unwrap_or(s.len())
1020 };
1021 let mut rest = line;
1022 while let Some(at) = rest.find(SKILL_URI_PREFIX) {
1023 let after = &rest[at + SKILL_URI_PREFIX.len()..];
1024 let id = after[..id_end(after)].trim_end_matches('-');
1025 if is_skill_id(id) {
1026 out.push((id.to_string(), format!("{SKILL_URI_PREFIX}{id}")));
1027 }
1028 rest = &after[id.len()..];
1029 }
1030 let mut rest = line;
1031 while let Some(at) = rest.find(skills_prefix) {
1032 let after = &rest[at + skills_prefix.len()..];
1033 let end = id_end(after);
1034 let id = &after[..end];
1035 if is_skill_id(id) && after[end..].starts_with("/SKILL.md") {
1036 out.push((id.to_string(), format!("{skills_prefix}{id}/SKILL.md")));
1037 }
1038 rest = &after[end..];
1039 }
1040 out
1041}
1042
1043fn verifying_gates(model: Option<&GateModel>, path: &str) -> Vec<String> {
1045 let Some(model) = model else {
1046 return Vec::new();
1047 };
1048 let verifying: BTreeSet<&str> = model
1049 .gates
1050 .iter()
1051 .filter(|g| g.runs.contains(SKILLS_VERIFY_COMMAND))
1052 .map(|g| g.id.as_str())
1053 .collect();
1054 let mut out: BTreeSet<String> = BTreeSet::new();
1055 for class in model.classes.iter().filter(|c| c.matches(path)) {
1056 if class.escalates() {
1057 out.extend(verifying.iter().map(|g| g.to_string()));
1058 } else {
1059 out.extend(
1060 class
1061 .named()
1062 .iter()
1063 .filter(|g| verifying.contains(g.as_str()))
1064 .cloned(),
1065 );
1066 }
1067 }
1068 out.into_iter().collect()
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074
1075 const M: &str = concat!("majordomus-", "skill:");
1076
1077 #[test]
1078 fn a_marker_binds_only_from_a_comment_line() {
1079 assert_eq!(marker_ids(&format!(" # {M} a b-2, c")), ["a", "b-2", "c"]);
1080 assert!(marker_ids(&format!("echo '{M} a'")).is_empty());
1081 assert!(marker_ids(&format!("# {M} Bad UPPER")).is_empty());
1082 assert!(marker_ids("# majordomus-covers: skills").is_empty());
1083 }
1084
1085 #[test]
1086 fn a_reference_is_a_uri_or_the_path_of_the_skill_file_and_nothing_near_it() {
1087 let p = ".ai/repo/skills/";
1088 assert!(references_in("see .ai/repo/skills/README.md", p).is_empty());
1089 assert!(references_in("see .ai/repo/skills/implement/examples/x.md", p).is_empty());
1090 assert_eq!(
1091 references_in("`majordomus://skill/deploy-site`.", p),
1092 [(
1093 "deploy-site".to_string(),
1094 "majordomus://skill/deploy-site".to_string()
1095 )]
1096 );
1097 assert_eq!(
1098 references_in("majordomus://skill/a majordomus://skill/b", p).len(),
1099 2
1100 );
1101 }
1102
1103 #[test]
1104 fn the_weakest_test_decides_and_no_test_is_weaker_than_any() {
1105 let states = [ProofState::Proven, ProofState::NotRun];
1106 let worst = states.iter().map(|s| SkillEvidence::of(*s)).max().unwrap();
1107 assert_eq!(worst, SkillEvidence::NotRun);
1108 assert!(SkillEvidence::Unrunnable < SkillEvidence::Untested);
1109 }
1110
1111 #[test]
1114 fn every_state_prints_as_the_word_it_serialises_to() {
1115 for e in [
1116 SkillEvidence::Proven,
1117 SkillEvidence::InputsUnchanged,
1118 SkillEvidence::Stale,
1119 SkillEvidence::Failing,
1120 SkillEvidence::NotRun,
1121 SkillEvidence::Unrunnable,
1122 SkillEvidence::Untested,
1123 ] {
1124 assert_eq!(
1125 serde_json::to_string(&e).unwrap(),
1126 format!("\"{}\"", e.label())
1127 );
1128 }
1129 for s in [
1130 SkillStanding::Proven,
1131 SkillStanding::Partial,
1132 SkillStanding::Orphan,
1133 SkillStanding::Invalid,
1134 SkillStanding::NotRequired,
1135 ] {
1136 assert_eq!(
1137 serde_json::to_string(&s).unwrap(),
1138 format!("\"{}\"", s.label())
1139 );
1140 }
1141 for (state, evidence) in [
1142 (ProofState::Proven, SkillEvidence::Proven),
1143 (ProofState::InputsUnchanged, SkillEvidence::InputsUnchanged),
1144 (ProofState::Stale, SkillEvidence::Stale),
1145 (ProofState::Failing, SkillEvidence::Failing),
1146 (ProofState::NotRun, SkillEvidence::NotRun),
1147 (ProofState::Unrunnable, SkillEvidence::Unrunnable),
1148 (ProofState::NoTest, SkillEvidence::Unrunnable),
1149 ] {
1150 assert_eq!(SkillEvidence::of(state), evidence, "{state:?}");
1151 }
1152 }
1153
1154 fn test_of(state: ProofState, reproduce: Option<&str>) -> SkillTest {
1155 SkillTest {
1156 path: "test/cases/01_x.sh".into(),
1157 test: None,
1158 state,
1159 reproduce: reproduce.map(str::to_string),
1160 recorded_at: None,
1161 }
1162 }
1163
1164 #[test]
1167 fn the_weakest_test_names_the_reproduce_command_or_the_explanation_does() {
1168 let explain = "majordomus skills explain x";
1169 let tests = [
1170 test_of(ProofState::Proven, Some("test/run.sh 01")),
1171 test_of(ProofState::Failing, Some("test/run.sh 02")),
1172 ];
1173 assert_eq!(weakest_reproduce(&tests, explain), "test/run.sh 02");
1174 let unrunnable = [
1175 test_of(ProofState::Proven, Some("test/run.sh 01")),
1176 test_of(ProofState::Unrunnable, None),
1177 ];
1178 assert_eq!(weakest_reproduce(&unrunnable, explain), explain);
1179 assert_eq!(weakest_reproduce(&[], explain), explain);
1180 }
1181
1182 #[test]
1185 fn a_prefix_without_an_id_is_no_reference_and_a_bare_directory_has_no_layer() {
1186 let p = ".ai/repo/skills/";
1187 assert!(references_in("majordomus://skill/ and majordomus://skill/Upper", p).is_empty());
1188 let surfaces = invocation_surfaces(&BTreeMap::new(), "skills");
1189 assert!(
1190 !surfaces.iter().any(|s| s.ends_with("providers/**")),
1191 "{surfaces:?}"
1192 );
1193 assert!(surfaces.contains(&"justfile".to_string()));
1194 }
1195
1196 #[test]
1199 fn a_tracked_file_that_cannot_be_read_binds_and_invokes_nothing() {
1200 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1201 let index = repo.index().unwrap();
1202 let root = repo.root();
1203 let workflows = index
1204 .repository
1205 .sections
1206 .get("workflows")
1207 .cloned()
1208 .expect("the synthetic manifest declares workflows");
1209 let write = |rel: &str, text: &str| {
1210 let p = root.join(rel);
1211 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1212 std::fs::write(p, text).unwrap();
1213 };
1214 write("test/cases/02_present.sh", &format!("# {M} alpha\ntrue\n"));
1215 write("test/cases/README.md", &format!("# {M} beta\n"));
1216 write(
1217 &format!("{workflows}/present.md"),
1218 "Follow majordomus://skill/alpha.\n",
1219 );
1220 let tracked: Vec<String> = [
1221 "test/cases/01_gone.sh",
1222 "test/cases/02_present.sh",
1223 "test/cases/README.md",
1224 &format!("{workflows}/gone.md"),
1225 &format!("{workflows}/present.md"),
1226 ]
1227 .iter()
1228 .map(|s| s.to_string())
1229 .collect();
1230
1231 let markers = test_markers(root, &tracked);
1232 assert_eq!(
1233 markers.keys().collect::<Vec<_>>(),
1234 ["test/cases/02_present.sh"]
1235 );
1236
1237 let invoked = invocation_references(root, &tracked, &index, ".ai/repo/skills");
1238 assert_eq!(invoked.len(), 1, "{invoked:?}");
1239 assert_eq!(invoked[0].0, "alpha");
1240 assert_eq!(invoked[0].1.path, format!("{workflows}/present.md"));
1241 assert_eq!(invoked[0].1.line, 1);
1242 }
1243
1244 #[test]
1247 fn an_escalating_class_selects_every_verifying_gate() {
1248 let model: GateModel = serde_json::from_value(serde_json::json!({
1249 "version": 1,
1250 "gates": [
1251 { "id": "named", "job": "j", "runs": "majordomus skills verify" },
1252 { "id": "unnamed", "job": "j", "runs": "x skills verify --strict" },
1253 { "id": "other", "job": "j", "runs": "true" }
1254 ],
1255 "classes": [
1256 { "id": "skills", "paths": [".ai/repo/skills/**"], "gates": ["named", "other"] },
1257 { "id": "ci", "paths": [".ai/repo/ci/**"], "gates": "full" }
1258 ]
1259 }))
1260 .unwrap();
1261 assert_eq!(
1262 verifying_gates(Some(&model), ".ai/repo/skills/a/SKILL.md"),
1263 ["named"]
1264 );
1265 assert_eq!(
1266 verifying_gates(Some(&model), ".ai/repo/ci/gates.yaml"),
1267 ["named", "unnamed"]
1268 );
1269 assert!(verifying_gates(None, ".ai/repo/skills/a/SKILL.md").is_empty());
1270 }
1271
1272 fn skill_object(path: &str, identity: &str, metadata: Value) -> crate::model::Object {
1273 crate::model::Object {
1274 kind: SKILL.into(),
1275 identity: identity.into(),
1276 uri: format!("majordomus://skill/{identity}"),
1277 title: None,
1278 description: None,
1279 metadata,
1280 body: String::new(),
1281 content: String::new(),
1282 media_type: "text/markdown",
1283 provenance: crate::model::Provenance {
1284 path: path.into(),
1285 directory: path.rsplit_once('/').unwrap().0.into(),
1286 source_class: "skill".into(),
1287 section: None,
1288 bytes: 0,
1289 member: None,
1290 },
1291 }
1292 }
1293
1294 #[test]
1300 fn a_contract_error_makes_a_skill_invalid_and_nothing_else_is_a_skill() {
1301 use crate::model::Diagnostic;
1302 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1303 let mut index = repo.index().unwrap();
1304 index.repository.sections.remove("skills");
1305 let plain = ".ai/repo/skills/plain/SKILL.md";
1306 index.objects.push(skill_object(
1307 plain,
1308 "plain",
1309 serde_json::json!({ "status": "active" }),
1310 ));
1311 for (path, message) in [
1312 (Some(plain), "a field is wrong"),
1313 (Some(".ai/repo/skills/refused/SKILL.md"), "refused outright"),
1314 (Some(".ai/repo/skills/refused/notes.md"), "beside a skill"),
1315 (Some(".ai/repo/skills/deep/er/SKILL.md"), "too deep"),
1316 (Some(".ai/repo/rules/x.md"), "elsewhere"),
1317 (None, "no file"),
1318 ] {
1319 index.diagnostics.push(Diagnostic::error(
1320 "schema",
1321 path.map(str::to_string),
1322 message,
1323 ));
1324 }
1325
1326 let skills = Skills::build(&index, &Ledger::empty());
1327 let ids: Vec<&str> = skills.skills.iter().map(|s| s.id.as_str()).collect();
1328 assert_eq!(ids, ["plain", "refused"]);
1329
1330 let s = skills.skill("plain").unwrap();
1331 assert_eq!(s.standing, SkillStanding::Invalid);
1332 assert!(!s.valid);
1333 assert_eq!(s.title, "plain", "no title: the id stands in");
1334 let contract: Vec<&SkillFinding> =
1335 s.findings.iter().filter(|f| f.code == "contract").collect();
1336 assert_eq!(contract.len(), 1);
1337 assert_eq!(contract[0].message, "schema: a field is wrong");
1338 assert_eq!(contract[0].reproduce, "majordomus skills check");
1339 assert_eq!(contract[0].level, SkillFindingLevel::Fail);
1340
1341 assert_eq!(
1342 skills.skill("refused").unwrap().standing,
1343 SkillStanding::Invalid
1344 );
1345 }
1346
1347 #[test]
1348 fn surfaces_follow_the_manifest_not_a_fixed_layout() {
1349 let mut sections = BTreeMap::new();
1350 sections.insert("workflows".to_string(), "layer/flows".to_string());
1351 let s = invocation_surfaces(§ions, "layer/skills");
1352 assert!(s.contains(&"layer/flows/**".to_string()));
1353 assert!(s.contains(&"layer/providers/**".to_string()));
1354 assert!(!s.iter().any(|p| p.starts_with(".ai/")));
1355 }
1356}