use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_staleness::stale_skills;
const CONVENTIONS_BEARING_SKILLS: &[&str] = &[
"documentation-style",
"tm-pr-workflow",
"tm-git-file-tracking",
];
pub(super) fn check_skill_staleness(paths: &FrameworkPaths) -> DoctorCheck {
let stale = stale_skills(&paths.skill_source_dir(), &paths.claude_skills_dir());
if stale.is_empty() {
return DoctorCheck::new(
"skill_staleness",
CheckStatus::Ok,
"deployed skills match the installed binary's bundled assets",
);
}
let drifted_conventions: Vec<&str> = stale
.iter()
.map(String::as_str)
.filter(|stem| CONVENTIONS_BEARING_SKILLS.contains(stem))
.collect();
let shown: Vec<&str> = stale.iter().take(5).map(String::as_str).collect();
let suffix = if stale.len() > shown.len() {
format!(", … (+{} more)", stale.len() - shown.len())
} else {
String::new()
};
if drifted_conventions.is_empty() {
return DoctorCheck::new(
"skill_staleness",
CheckStatus::Warn,
format!(
"{} deployed skill(s) are stale relative to bundled assets ({}{}) — \
relaunch the session or run `tm install` to refresh",
stale.len(),
shown.join(", "),
suffix
),
);
}
DoctorCheck::new(
"skill_staleness",
CheckStatus::Fail,
format!(
"conventions-bearing skill(s) have drifted from bundled assets \
({}) — a framework-guaranteed convention (e.g. the commit/PR \
attribution footer or Why/What/Test doc pattern) may have \
silently lapsed in the deployed copy; run `tm install` now to \
restore the bundled version. Other stale skill(s) ({}{}) are \
lower-severity but should also be refreshed by the same command.",
drifted_conventions.join(", "),
shown.join(", "),
suffix
),
)
}
pub(super) fn check_legacy_instruction_sources(home: &Path) -> DoctorCheck {
let mut findings: Vec<String> = Vec::new();
let tm_skill_count = count_legacy_tm_skills(&home.join(".claude").join("skills"));
if tm_skill_count > 0 {
findings.push(format!(
"{tm_skill_count} `tm-*` skill copy(ies) in ~/.claude/skills (legacy global \
deploy — remove them or run `tm install` to refresh; project-local \
.claude/skills is now authoritative)"
));
}
let legacy_config = home.join(".trusty-mpm").join("claude-config");
if legacy_config.is_dir() {
findings.push(
"legacy ~/.trusty-mpm/claude-config directory (superseded by the tm-owned \
~/.trusty-tools config home — safe to delete once no session references it)"
.to_string(),
);
}
if findings.is_empty() {
DoctorCheck::new(
"legacy_sources",
CheckStatus::Ok,
"no legacy global instruction sources found",
)
} else {
DoctorCheck::new(
"legacy_sources",
CheckStatus::Warn,
format!(
"legacy instruction source(s) may serve stale guidance: {}",
findings.join("; ")
),
)
}
}
fn count_legacy_tm_skills(skills_dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(skills_dir) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|n| n.starts_with("tm-"))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::skill_deployer::deploy_skills;
#[test]
fn staleness_ok_when_fresh() {
let tmp = tempfile::tempdir().unwrap();
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = None;
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-doctor.md"), "v1").unwrap();
deploy_skills(&source, &paths.claude_skills_dir()).unwrap();
let check = check_skill_staleness(&paths);
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
}
#[test]
fn staleness_warns_when_stale() {
let tmp = tempfile::tempdir().unwrap();
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = None;
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-doctor.md"), "v1").unwrap();
deploy_skills(&source, &paths.claude_skills_dir()).unwrap();
std::fs::write(source.join("tm-doctor.md"), "v2").unwrap();
let check = check_skill_staleness(&paths);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("tm-doctor"));
assert!(check.message.contains("tm install"));
}
#[test]
fn staleness_escalates_when_conventions_skill_stale() {
let tmp = tempfile::tempdir().unwrap();
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = None;
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-pr-workflow.md"), "v1").unwrap();
deploy_skills(&source, &paths.claude_skills_dir()).unwrap();
std::fs::write(source.join("tm-pr-workflow.md"), "v2").unwrap();
let check = check_skill_staleness(&paths);
assert_eq!(
check.status,
CheckStatus::Fail,
"message: {}",
check.message
);
assert!(check.message.contains("tm-pr-workflow"));
assert!(check.message.contains("tm install"));
assert!(check.message.to_lowercase().contains("convention"));
}
#[test]
fn staleness_warns_when_ordinary_skill_stale() {
let tmp = tempfile::tempdir().unwrap();
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = None;
let source = paths.skill_source_dir();
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("tm-doctor.md"), "v1").unwrap();
std::fs::write(source.join("tm-pr-workflow.md"), "v1").unwrap();
deploy_skills(&source, &paths.claude_skills_dir()).unwrap();
std::fs::write(source.join("tm-doctor.md"), "v2").unwrap();
let check = check_skill_staleness(&paths);
assert_eq!(
check.status,
CheckStatus::Warn,
"message: {}",
check.message
);
assert!(check.message.contains("tm-doctor"));
}
#[test]
fn legacy_sources_ok_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn legacy_sources_warns_on_tm_skills() {
let tmp = tempfile::tempdir().unwrap();
let skills = tmp.path().join(".claude").join("skills");
let skill_dir = skills.join("tm-pr-workflow");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "old attribution").unwrap();
std::fs::create_dir_all(skills.join("my-skill")).unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains(".claude/skills"));
assert!(check.message.contains('1'));
}
#[test]
fn legacy_sources_warns_on_claude_config() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".trusty-mpm").join("claude-config")).unwrap();
let check = check_legacy_instruction_sources(tmp.path());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("claude-config"));
}
}