use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use trusty_agents_common::skills::source_scan::scan_skill_sources;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::frontmatter::{parse_kv_line, validate_frontmatter};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deploy_tiers::skill_deploy_tiers;
const CHECK_NAME: &str = "skill_reachability";
const MAX_NAMED: usize = 5;
struct DeployedCopy {
label: &'static str,
entry: PathBuf,
}
pub(super) fn check_skill_reachability(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
) -> DoctorCheck {
let source = paths.skill_source_dir();
let roster: Vec<String> = match scan_skill_sources(&source) {
Ok(scan) => scan.skills.into_iter().map(|skill| skill.stem).collect(),
Err(e) => {
return unknown(format!(
"the bundled skill roster at {} could not be read, so which skills should be \
reachable is undetermined: {e}",
source.display()
));
}
};
if roster.is_empty() {
return unknown(format!(
"no bundled skill source found at {} — nothing declares which skills should be \
deployed, so reachability is undetermined (run `tm install` to populate it)",
source.display()
));
}
let tiers = skill_deploy_tiers(paths, project_dir);
let mut deployed: BTreeMap<String, Vec<DeployedCopy>> = BTreeMap::new();
for tier in &tiers {
let scan = match scan_skill_sources(&tier.dir) {
Ok(scan) => scan,
Err(e) => {
return unknown(format!(
"the {} skill tier at {} could not be read, so whether the bundled roster \
is reachable is undetermined: {e}",
tier.label,
tier.dir.display()
));
}
};
for skill in scan.skills {
deployed.entry(skill.stem).or_default().push(DeployedCopy {
label: tier.label,
entry: skill.entry,
});
}
}
let searched = tiers
.iter()
.map(|tier| format!("{} ({})", tier.label, tier.dir.display()))
.collect::<Vec<_>>()
.join(", ");
let mut unreachable: Vec<String> = Vec::new();
let mut shadowed: Vec<String> = Vec::new();
for stem in &roster {
let Some(copies) = deployed.get(stem) else {
unreachable.push(format!(
"`{stem}` is in the bundled roster but present in no tier the harness reads \
(searched {searched})"
));
continue;
};
let winner = &copies[0];
if let Some(reason) = frontmatter_defect(&winner.entry, stem) {
unreachable.push(format!("`{stem}` at {}: {reason}", winner.entry.display()));
}
if let Some(finding) = shadow_finding(stem, copies) {
shadowed.push(finding);
}
}
verdict(roster.len(), unreachable, shadowed)
}
fn frontmatter_defect(entry: &Path, stem: &str) -> Option<String> {
let content = match std::fs::read_to_string(entry) {
Ok(content) => content,
Err(e) => return Some(format!("its entry point could not be read: {e}")),
};
if let Err(detail) = validate_frontmatter(&content) {
return Some(format!(
"its frontmatter does not parse ({detail}), so the harness cannot load it"
));
}
let Some(name) = declared_name(&content) else {
return Some(
"its frontmatter declares no `name`, so the harness has nothing to resolve it by"
.to_string(),
);
};
if name != stem {
return Some(format!(
"its frontmatter declares `name: {name}` but it is deployed as `{stem}` — the \
harness resolves the deployed name, so the skill is unreachable under the name it \
claims"
));
}
None
}
fn declared_name(content: &str) -> Option<String> {
let mut lines = content.lines();
lines.next()?;
for line in lines {
if line.trim() == "---" {
break;
}
if let Some((key, value)) = parse_kv_line(line)
&& key == "name"
{
return Some(value);
}
}
None
}
fn shadow_finding(stem: &str, copies: &[DeployedCopy]) -> Option<String> {
let (winner, losers) = copies.split_first()?;
if losers.is_empty() {
return None;
}
let shadowed = losers
.iter()
.map(|copy| format!("{} ({})", copy.label, copy.entry.display()))
.collect::<Vec<_>>()
.join(", ");
let delete = losers
.iter()
.map(|copy| copy.entry.display().to_string())
.collect::<Vec<_>>()
.join(" and ");
Some(format!(
"`{stem}` is deployed at {} tiers — the {} copy at {} is the one the harness loads and \
it SHADOWS {shadowed}. Bundled skills are user-tier only (owner ruling, #6586), so \
delete the shadowed copy at {delete}",
copies.len(),
winner.label,
winner.entry.display()
))
}
fn verdict(rostered: usize, unreachable: Vec<String>, shadowed: Vec<String>) -> DoctorCheck {
if unreachable.is_empty() && shadowed.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"all {rostered} bundled skill(s) are reachable — each is deployed in a tier the \
harness reads, parses, and resolves under its own name, with no tier shadowing \
another (#4947)"
),
);
}
let status = if unreachable.is_empty() {
CheckStatus::Warn
} else {
CheckStatus::Fail
};
let mut findings = unreachable;
findings.extend(shadowed);
let shown: Vec<&str> = findings
.iter()
.take(MAX_NAMED)
.map(String::as_str)
.collect();
let suffix = if findings.len() > shown.len() {
format!(" … (+{} more)", findings.len() - shown.len())
} else {
String::new()
};
DoctorCheck::new(
CHECK_NAME,
status,
format!(
"{} reachability finding(s) across the {rostered} bundled skill(s) the framework \
declares deployable: {}{suffix} (issue #4947)",
findings.len(),
shown.join("; ")
),
)
}
fn unknown(message: String) -> DoctorCheck {
DoctorCheck::new(CHECK_NAME, CheckStatus::Unknown, message)
}
#[cfg(test)]
#[path = "doctor_skill_reachability_tests.rs"]
mod tests;