Skip to main content

majordomus_cli/skill/
mod.rs

1//! Skills as proven capabilities: what the repository can show about each skill, derived.
2//!
3//! A skill is data: `SKILL.md` under the layer's skills section, discovered by the source class
4//! `skill` and validated against its schema by the index (ADR 0007). That makes a skill
5//! *exist*. It says nothing about whether the procedure is exercised by anything, reached by
6//! anything, published anywhere or held by any gate — and a catalogue whose entries are
7//! merely present is the hand-maintained registry this repository refuses, one level up.
8//!
9//! So four facts are derived here, per skill, on every read, and none is authored:
10//!
11//! * **tested** — a behavioural case or crate test names the skill with the marker
12//!   `majordomus-skill: <id>` on a comment line, and the evidence ledger holds a run of that
13//!   test. The run is judged by the same judgement a rule's
14//!   named test gets, so a test cannot be current for a rule and stale for a skill;
15//! * **documented** — the site's page projection for the skill is a tracked file;
16//! * **enforced** — a dispatched rule validates skills (its validator is `skills`) and a CI
17//!   gate whose command runs the skills verification is selected by a change to the skill;
18//! * **used** — an invocation surface (a workflow, a prompt, a profile, a provider template,
19//!   a recipe, a CI workflow, a root bootstrap) references the skill by its URI
20//!   `majordomus://skill/<id>` or by the path of its `SKILL.md`.
21//!
22//! An active skill that no test names, or that nothing invokes, is an **orphan**: a failure.
23//! A test that names a skill that does not exist, and an invocation that references one, are
24//! failures too — a binding to nothing reads as proof. Evidence that is not current, a missing
25//! page and a missing gate are warnings: each is a debt with a command that settles it, and
26//! none of them can be settled by the author of the skill alone. A draft or deprecated skill
27//! owes nothing.
28//!
29//! ```
30//! use majordomus_cli::evidence::Ledger;
31//! use majordomus_cli::skill::Skills;
32//! use majordomus_cli::synthetic::SyntheticRepository;
33//!
34//! let repo = SyntheticRepository::small().unwrap();
35//! let index = repo.index().unwrap();
36//! let skills = Skills::build(&index, &Ledger::empty());
37//! // every skill the index holds is answered, and a repository with none owes nothing
38//! assert_eq!(
39//!     skills.skills.len(),
40//!     index.objects.iter().filter(|o| o.kind == "skill").count()
41//! );
42//! assert_eq!(skills.failures() + skills.warnings(), skills.findings.len());
43//! ```
44
45use 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
58/// The kind of a skill in the index.
59pub const SKILL: &str = "skill";
60
61/// The marker a test carries, on a comment line, to name the skills it exercises:
62/// `# majordomus-skill: implement repo-review` in a case, `//! majordomus-skill: implement`
63/// in a crate test.
64pub const TEST_MARKER: &str = "majordomus-skill:";
65
66/// The URI prefix an invocation references a skill by.
67pub const SKILL_URI_PREFIX: &str = "majordomus://skill/";
68
69/// Where the site's page for a skill is projected, repository-relative: `<dir>/<id>.md`.
70pub const SKILL_PAGES_DIR: &str = "site/content/skills";
71
72/// The words a gate's command carries when it runs the skills verification. A gate is read
73/// by what it runs, never by its id, so a gate renamed in the CI model is renamed here.
74pub const SKILLS_VERIFY_COMMAND: &str = "skills verify";
75
76/// The validator a dispatched rule names when it is the doctrine that validates skills.
77pub const SKILLS_VALIDATOR: &str = "skills";
78
79/// Where tests live that a skill marker is read from: the behavioural cases and the crate's
80/// integration tests, the two runners the evidence ledger records.
81const TEST_DIRS: &[&str] = &["test/cases", "apps/majordomus-cli/tests"];
82
83/// The invocation surfaces outside the layer's own sections: recipes, CI workflows and the
84/// root bootstraps. The layer's workflows, prompts, profiles and provider templates are added
85/// from the manifest's sections by [`invocation_surfaces`].
86const ROOT_SURFACES: &[&str] = &[
87    "justfile",
88    ".just/**",
89    ".github/workflows/**",
90    "AGENTS.md",
91    "CLAUDE.md",
92    "GEMINI.md",
93];
94
95// ---------------------------------------------------------------- vocabulary
96
97/// Where a skill came from, when it was not written here first: an opaque marker into a
98/// ledger kept outside the repository. The committed half names no source; the ledger entry
99/// it points at does, locally.
100///
101/// ```
102/// use majordomus_cli::skill::SkillProvenance;
103/// let p = SkillProvenance {
104///     origin: "prior-art".into(),
105///     ledger: "import-2026-09-09#1".into(),
106///     decision: "adapted".into(),
107/// };
108/// assert!(p.ledger.starts_with("import-"));
109/// ```
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111pub struct SkillProvenance {
112    /// `prior-art`: the concept was studied elsewhere before this skill was written.
113    pub origin: String,
114    /// `import-<date>#<n>`: the entry of the local import ledger that holds the mapping.
115    pub ledger: String,
116    /// `adapted`, `reimplemented` or `merged`.
117    pub decision: String,
118}
119
120/// What the evidence ledger can say about a skill's tests, as a whole: the weakest state of
121/// the tests that name it, or `untested` when none does.
122///
123/// ```
124/// use majordomus_cli::skill::SkillEvidence;
125/// assert!(SkillEvidence::Proven.current());
126/// assert!(SkillEvidence::InputsUnchanged.current());
127/// assert!(!SkillEvidence::Stale.current());
128/// assert!(SkillEvidence::Proven < SkillEvidence::Untested);
129/// ```
130#[derive(
131    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
132)]
133#[serde(rename_all = "snake_case")]
134pub enum SkillEvidence {
135    /// Every naming test has a passing run, and nothing but the ledger changed since.
136    Proven,
137    /// Every naming test passed, and neither the test nor the skill changed since.
138    InputsUnchanged,
139    /// A passing run exists, and the test or the skill changed since.
140    Stale,
141    /// The latest recorded run of a naming test did not pass.
142    Failing,
143    /// A test names the skill and no run of it was ever recorded.
144    NotRun,
145    /// A test names the skill from a path no runner drives.
146    Unrunnable,
147    /// No test names the skill.
148    Untested,
149}
150
151impl SkillEvidence {
152    /// Does this state carry a passing run nothing known has invalidated?
153    ///
154    /// ```
155    /// use majordomus_cli::skill::SkillEvidence;
156    /// assert!(!SkillEvidence::NotRun.current());
157    /// ```
158    pub fn current(self) -> bool {
159        matches!(self, SkillEvidence::Proven | SkillEvidence::InputsUnchanged)
160    }
161
162    /// The word every surface prints for this evidence state: the text listing, the JSON
163    /// answer and the site page all read it here, so they cannot spell a state two ways.
164    ///
165    /// ```
166    /// use majordomus_cli::skill::SkillEvidence;
167    /// assert_eq!(SkillEvidence::InputsUnchanged.label(), "inputs_unchanged");
168    /// ```
169    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    /// The skill-level word for one test's proof state.
182    ///
183    /// ```
184    /// use majordomus_cli::evidence::ProofState;
185    /// use majordomus_cli::skill::SkillEvidence;
186    /// assert_eq!(SkillEvidence::of(ProofState::NotRun), SkillEvidence::NotRun);
187    /// ```
188    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/// Where a skill stands, derived from the four facts.
201///
202/// ```
203/// use majordomus_cli::skill::SkillStanding;
204/// assert_eq!(SkillStanding::Orphan.label(), "orphan");
205/// ```
206#[derive(
207    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
208)]
209#[serde(rename_all = "snake_case")]
210pub enum SkillStanding {
211    /// Active, and tested with current evidence, documented, enforced and used.
212    Proven,
213    /// Active, named by a test and invoked, with at least one debt a warning names.
214    Partial,
215    /// Active, and no test names it or nothing invokes it.
216    Orphan,
217    /// Its file does not satisfy the skill contract; one the index refused outright is listed
218    /// here too, so a listing never silently shrinks.
219    Invalid,
220    /// Draft or deprecated: nothing is owed.
221    NotRequired,
222}
223
224impl SkillStanding {
225    /// The word every surface prints for this standing, the same snake_case word the JSON
226    /// answer serialises, so a script filtering on it and a person reading it agree.
227    ///
228    /// ```
229    /// use majordomus_cli::skill::SkillStanding;
230    /// assert_eq!(SkillStanding::NotRequired.label(), "not_required");
231    /// ```
232    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/// How bad a finding is: a failure refuses, a warning is reported.
244///
245/// ```
246/// use majordomus_cli::skill::SkillFindingLevel;
247/// assert_ne!(SkillFindingLevel::Fail, SkillFindingLevel::Warn);
248/// ```
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
250#[serde(rename_all = "snake_case")]
251pub enum SkillFindingLevel {
252    /// Refuses: `skills verify` exits 10 and the doctrine fails.
253    Fail,
254    /// Reported, and does not refuse.
255    Warn,
256}
257
258/// One thing the derivation found wrong, with the command that settles or reproduces it.
259///
260/// ```
261/// use majordomus_cli::skill::{SkillFinding, SkillFindingLevel};
262/// let f = SkillFinding {
263///     level: SkillFindingLevel::Fail,
264///     code: "untested".into(),
265///     skill: Some("implement".into()),
266///     subject: ".ai/repo/skills/implement/SKILL.md".into(),
267///     message: "no test names this skill".into(),
268///     reproduce: "majordomus skills explain implement".into(),
269/// };
270/// assert!(f.reproduce.contains("explain"));
271/// ```
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
273pub struct SkillFinding {
274    /// `fail` or `warn`.
275    pub level: SkillFindingLevel,
276    /// A stable code: `contract`, `untested`, `unused`, `unevidenced`, `failing`,
277    /// `undocumented`, `unenforced`, `unknown_skill_in_test`, `unknown_skill_invoked`.
278    pub code: String,
279    /// The skill concerned, when the finding is about one.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub skill: Option<String>,
282    /// The file the finding is about, repository-relative.
283    pub subject: String,
284    /// What is wrong, and what to do about it.
285    pub message: String,
286    /// The command that reproduces or settles it.
287    pub reproduce: String,
288}
289
290/// One test that names a skill, and what the ledger says about it.
291///
292/// ```
293/// use majordomus_cli::evidence::ProofState;
294/// use majordomus_cli::skill::SkillTest;
295/// let t = SkillTest {
296///     path: "test/cases/95_skills.sh".into(),
297///     test: Some("suite:95_skills".into()),
298///     state: ProofState::NotRun,
299///     reproduce: Some("bash test/run.sh 95_skills".into()),
300///     recorded_at: None,
301/// };
302/// assert_eq!(t.state, ProofState::NotRun);
303/// ```
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct SkillTest {
306    /// The test's path.
307    pub path: String,
308    /// Its identity in the ledger, when a runner owns the path.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub test: Option<String>,
311    /// What the ledger says about its latest run, for this skill.
312    pub state: ProofState,
313    /// The command that runs it again.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub reproduce: Option<String>,
316    /// When the run behind the state was recorded.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub recorded_at: Option<String>,
319}
320
321/// The tested fact: the tests that name the skill, and their weakest state.
322///
323/// ```
324/// use majordomus_cli::skill::{SkillEvidence, SkillTested};
325/// let t = SkillTested { state: SkillEvidence::Untested, tests: vec![] };
326/// assert!(!t.state.current());
327/// ```
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
329pub struct SkillTested {
330    /// The weakest state of the naming tests; `untested` when there are none.
331    pub state: SkillEvidence,
332    /// Every test that names the skill, in path order.
333    pub tests: Vec<SkillTest>,
334}
335
336/// The documented fact: the page the site projects for the skill.
337///
338/// ```
339/// use majordomus_cli::skill::SkillDocumented;
340/// let d = SkillDocumented { documented: true, page: "site/content/skills/implement.md".into() };
341/// assert!(d.page.ends_with("implement.md"));
342/// ```
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
344pub struct SkillDocumented {
345    /// Whether the page is a tracked file.
346    pub documented: bool,
347    /// Where the page is projected.
348    pub page: String,
349}
350
351/// The enforced fact: the doctrine that validates skills and the gates a change to this one
352/// selects.
353///
354/// ```
355/// use majordomus_cli::skill::SkillEnforced;
356/// let e = SkillEnforced { enforced: false, doctrine: None, gates: vec![] };
357/// assert!(!e.enforced);
358/// ```
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
360pub struct SkillEnforced {
361    /// A doctrine validates skills, and a gate running the verification covers this file.
362    pub enforced: bool,
363    /// The rule whose validator is `skills`, when the index holds one.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub doctrine: Option<String>,
366    /// The CI gates a change to the skill selects whose command runs `skills verify`.
367    pub gates: Vec<String>,
368}
369
370/// One place a skill is invoked from.
371///
372/// ```
373/// use majordomus_cli::skill::SkillInvocation;
374/// let i = SkillInvocation {
375///     path: ".ai/repo/prompts/review.md".into(),
376///     line: 3,
377///     reference: "majordomus://skill/repo-review".into(),
378/// };
379/// assert_eq!(i.line, 3);
380/// ```
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct SkillInvocation {
383    /// The invocation surface, repository-relative.
384    pub path: String,
385    /// The 1-based line the reference is on.
386    pub line: usize,
387    /// The reference as written.
388    pub reference: String,
389}
390
391/// The used fact: every invocation surface that references the skill.
392///
393/// ```
394/// use majordomus_cli::skill::SkillUsed;
395/// let u = SkillUsed { used: false, invocations: vec![] };
396/// assert!(!u.used);
397/// ```
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399pub struct SkillUsed {
400    /// At least one invocation surface references the skill.
401    pub used: bool,
402    /// Every reference, in path and line order.
403    pub invocations: Vec<SkillInvocation>,
404}
405
406/// One skill with everything derived about it.
407///
408/// ```
409/// use majordomus_cli::skill::SkillStatus;
410/// // every fact is always present in the answer, so a client never has to tell "absent"
411/// // from "false"; only the provenance is optional
412/// let schema = serde_json::to_value(schemars::schema_for!(SkillStatus)).unwrap();
413/// let required = schema["required"].as_array().unwrap();
414/// for field in ["id", "valid", "tested", "documented", "enforced", "used", "standing"] {
415///     assert!(required.iter().any(|f| f == field), "{field}");
416/// }
417/// assert!(!required.iter().any(|f| f == "provenance"));
418/// ```
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420pub struct SkillStatus {
421    /// The skill's id, which is its directory name.
422    pub id: String,
423    /// `majordomus://skill/<id>`.
424    pub uri: String,
425    /// The title as authored.
426    pub title: String,
427    /// The description as authored.
428    pub description: String,
429    /// The lifecycle status as authored: `draft`, `active` or `deprecated`; `unknown` for a
430    /// file the index refused.
431    pub status: String,
432    /// The version as authored.
433    pub version: u64,
434    /// The file, repository-relative.
435    pub path: String,
436    /// Where it came from, when it declares that.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub provenance: Option<SkillProvenance>,
439    /// Whether the file satisfies the skill contract, as the index judged it.
440    pub valid: bool,
441    /// The tested fact.
442    pub tested: SkillTested,
443    /// The documented fact.
444    pub documented: SkillDocumented,
445    /// The enforced fact.
446    pub enforced: SkillEnforced,
447    /// The used fact.
448    pub used: SkillUsed,
449    /// Where it stands, derived from the four.
450    pub standing: SkillStanding,
451    /// The findings about this skill.
452    pub findings: Vec<SkillFinding>,
453}
454
455/// Every skill of a repository, derived, and every finding.
456///
457/// ```
458/// use majordomus_cli::evidence::Ledger;
459/// use majordomus_cli::skill::Skills;
460/// use majordomus_cli::synthetic::SyntheticRepository;
461/// let repo = SyntheticRepository::small().unwrap();
462/// let skills = Skills::build(&repo.index().unwrap(), &Ledger::empty());
463/// assert!(skills.skill("no-such-skill").is_none());
464/// ```
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
466pub struct Skills {
467    /// Every skill, in index order.
468    pub skills: Vec<SkillStatus>,
469    /// Every finding: each skill's, then those about no one skill (a binding to nothing).
470    pub findings: Vec<SkillFinding>,
471}
472
473impl Skills {
474    /// Derive every skill's standing from the index, the evidence ledger, the tracked tests,
475    /// the tracked invocation surfaces and the CI model.
476    ///
477    /// ```
478    /// use majordomus_cli::evidence::Ledger;
479    /// use majordomus_cli::skill::Skills;
480    /// use majordomus_cli::synthetic::SyntheticRepository;
481    /// let repo = SyntheticRepository::small().unwrap();
482    /// let a = Skills::build(&repo.index().unwrap(), &Ledger::empty());
483    /// let b = Skills::build(&repo.index().unwrap(), &Ledger::empty());
484    /// assert_eq!(a, b, "two reads of one tree derive the same answer");
485    /// ```
486    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            // tested
537            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            // documented
600            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            // enforced
614            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            // used
637            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        // a skill file the index refused holds no object, and a listing that dropped it would
700        // silently shrink: it is answered as invalid, with every diagnostic as a finding
701        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        // bindings to nothing: a test or an invocation naming a skill that is not there
768        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    /// One skill by id, the directory name under `.ai/repo/skills/`; `None` only when the
803    /// repository has no skill of that name, since one the index refused is still listed,
804    /// standing `invalid`.
805    ///
806    /// ```
807    /// use majordomus_cli::skill::Skills;
808    /// let empty = Skills { skills: vec![], findings: vec![] };
809    /// assert!(empty.skill("implement").is_none());
810    /// ```
811    pub fn skill(&self, id: &str) -> Option<&SkillStatus> {
812        self.skills.iter().find(|s| s.id == id)
813    }
814
815    /// How many findings refuse: `skills verify` is valid only when this is zero. Warnings
816    /// are counted apart, by [`Skills::warnings`], and never make it invalid.
817    ///
818    /// ```
819    /// use majordomus_cli::skill::Skills;
820    /// assert_eq!(Skills { skills: vec![], findings: vec![] }.failures(), 0);
821    /// ```
822    pub fn failures(&self) -> usize {
823        self.findings
824            .iter()
825            .filter(|f| f.level == SkillFindingLevel::Fail)
826            .count()
827    }
828
829    /// How many findings are warnings: debts a skill still owes that do not refuse the
830    /// verdict, such as a partial standing. Refusals are counted by [`Skills::failures`].
831    ///
832    /// ```
833    /// use majordomus_cli::skill::Skills;
834    /// assert_eq!(Skills { skills: vec![], findings: vec![] }.warnings(), 0);
835    /// ```
836    pub fn warnings(&self) -> usize {
837        self.findings
838            .iter()
839            .filter(|f| f.level == SkillFindingLevel::Warn)
840            .count()
841    }
842}
843
844// ---------------------------------------------------------------- derivations
845
846fn 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
871/// The command that reproduces the weakest of `tests`, or `otherwise` when that test names
872/// no runner that could reproduce it.
873fn 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
887/// The skill ids a marker line names, when the line is a comment carrying the marker.
888///
889/// Only a comment line counts — `#`, `//` or `//!` first — so that a test which writes a
890/// marker into a fixture, as data, does not bind itself to the skill it writes.
891///
892/// ```
893/// use majordomus_cli::skill::marker_ids;
894/// let marker = concat!("majordomus-", "skill:");
895/// assert_eq!(marker_ids(&format!("# {marker} implement repo-review")), ["implement", "repo-review"]);
896/// assert_eq!(marker_ids(&format!("//! {marker} implement")), ["implement"]);
897/// assert!(marker_ids(&format!("printf '# {marker} x'")).is_empty());
898/// ```
899pub 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
919/// Every tracked test a runner owns that carries a marker, with the ids it names.
920fn 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
940/// The patterns of every invocation surface: the layer's workflows, prompts, profiles and
941/// provider templates (read from the manifest's sections, beside the skills section), and the
942/// recipes, CI workflows and root bootstraps.
943///
944/// ```
945/// use majordomus_cli::skill::invocation_surfaces;
946/// let mut sections = std::collections::BTreeMap::new();
947/// sections.insert("prompts".to_string(), ".ai/repo/prompts".to_string());
948/// let s = invocation_surfaces(&sections, ".ai/repo/skills");
949/// assert!(s.contains(&".ai/repo/prompts/**".to_string()));
950/// assert!(s.contains(&".ai/repo/providers/**".to_string()));
951/// assert!(s.contains(&"justfile".to_string()));
952/// ```
953pub 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
966/// Every reference an invocation surface makes to a skill: `(target id, where)`, in path and
967/// line order.
968fn 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
1003/// The skill references one line makes: `majordomus://skill/<id>`, and
1004/// `<skills dir>/<id>/SKILL.md`.
1005///
1006/// ```
1007/// use majordomus_cli::skill::references_in;
1008/// let r = references_in(
1009///     "Follow majordomus://skill/repo-review, then .ai/repo/skills/implement/SKILL.md.",
1010///     ".ai/repo/skills/",
1011/// );
1012/// assert_eq!(r[0].0, "repo-review");
1013/// assert_eq!(r[1], ("implement".to_string(), ".ai/repo/skills/implement/SKILL.md".to_string()));
1014/// ```
1015pub 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
1043/// The gates a change to `path` selects whose command runs the skills verification.
1044fn 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    /// Every evidence state and every standing prints as the word it serialises to, and
1112    /// every proof state of a test maps to the evidence word a skill carries.
1113    #[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    /// The finding's reproduce command is the weakest test's, because that is the run that
1165    /// would change the verdict; with no runner to name, it is the explanation instead.
1166    #[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    /// A URI prefix followed by no id is no reference, and a skills directory with no parent
1183    /// layer adds no provider surface.
1184    #[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    /// A tracked test or surface that is gone from the disk contributes nothing, and a
1197    /// tracked file under a test directory that is no test is never read for a marker.
1198    #[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    /// A class that escalates selects every gate that verifies skills; a class that names
1245    /// gates selects only the verifying ones it names; no model selects nothing.
1246    #[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    /// A skill the index holds with an error against it is invalid and the error is its
1295    /// finding; its id and title fall back to its identity when the file declares neither.
1296    /// A skill file the index refused is listed as invalid too, under the skills directory
1297    /// the layout implies when the manifest names none; an error elsewhere, or on a file
1298    /// beside a skill, or on no file at all, is no skill.
1299    #[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(&sections, "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}