use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deploy_tiers::skill_deploy_tiers;
use crate::core::skill_drift::{
ManifestState, SkillDrift, SkillReference, audit_deployed_skills_with_roster, key_stem,
skill_reference,
};
const CHECK_NAME: &str = "skill_staleness";
const MAX_NAMED: usize = 5;
const CONVENTIONS_BEARING_SKILLS: &[&str] =
&["documentation-style", "tm-workflow", "tm-git-file-tracking"];
pub(super) fn check_skill_staleness(
paths: &FrameworkPaths,
project_dir: Option<&Path>,
) -> DoctorCheck {
let source = paths.skill_source_dir();
let submodule = (source != paths.skills).then_some(source);
let content = crate::core::content_source::framework_content_local(project_dir).ok();
let reference = skill_reference(submodule.as_deref(), content.as_ref());
report(&reference, paths, project_dir)
}
fn report(
reference: &SkillReference,
paths: &FrameworkPaths,
project_dir: Option<&Path>,
) -> DoctorCheck {
if reference.assets.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
"no bundled skill assets are available to compare against — deployed skill \
content cannot be verified (issue #4604)",
);
}
let mut drifted: Vec<(&'static str, String)> = Vec::new();
let mut frozen: Vec<String> = Vec::new();
let mut missing: Vec<String> = Vec::new();
let mut unverifiable: Vec<String> = Vec::new();
let mut conventions: Vec<String> = Vec::new();
for tier in skill_deploy_tiers(paths, project_dir) {
let audit =
audit_deployed_skills_with_roster(reference, &tier.dir, tier.receives_bundled_roster);
match &audit.manifest {
ManifestState::Present | ManifestState::AbsentAndEmpty => {}
ManifestState::AbsentButPopulated => unverifiable.push(format!(
"{} (skills are deployed at {} but its `{}` ownership ledger is absent — nothing there can be attributed to tm or to the operator)",
tier.label,
tier.dir.display(),
crate::core::skill_manifest::SKILL_MANIFEST_FILE,
)),
ManifestState::Unreadable(why) => {
unverifiable.push(format!("{} ({why})", tier.label))
}
}
for finding in audit.findings {
let where_ = format!("{}/{}", tier.label, finding.stem);
let conventions_bearing = CONVENTIONS_BEARING_SKILLS.contains(&key_stem(&finding.stem));
match finding.state {
SkillDrift::Fresh => continue,
SkillDrift::Drifted => drifted.push((tier.label, where_.clone())),
SkillDrift::DriftedFrozen => frozen.push(where_.clone()),
SkillDrift::Missing => missing.push(where_.clone()),
SkillDrift::Unverifiable(why) => {
unverifiable.push(format!("{where_} ({why})"));
continue;
}
}
if conventions_bearing {
conventions.push(where_);
}
}
}
if drifted.is_empty() && frozen.is_empty() && missing.is_empty() && unverifiable.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"every deployed skill matches {} at every deploy tier",
reference.origin
),
);
}
let mut parts: Vec<String> = Vec::new();
parts.extend(drift_parts(&drifted));
if !frozen.is_empty() {
parts.push(format!(
"{} drifted and FROZEN — hand-edited after deployment, so `tm install` will \
deliberately skip them and they stay stale ({}); re-deploy with \
`tm doctor --fix-skills --include-frozen --yes` (backs each file up first)",
frozen.len(),
summarise(&frozen)
));
}
if !missing.is_empty() {
parts.push(format!(
"{} ABSENT from disk ({}) — recorded as deployed and since removed, or bundled in \
this binary and never deployed here; `tm doctor --fix --yes` writes them",
missing.len(),
summarise(&missing)
));
}
if !unverifiable.is_empty() {
parts.push(format!(
"{} could NOT be verified ({})",
unverifiable.len(),
summarise(&unverifiable)
));
}
let body = format!(
"compared against {}: {} (issue #4604)",
reference.origin,
parts.join("; ")
);
let status = if !conventions.is_empty() {
CheckStatus::Fail
} else if !unverifiable.is_empty() {
CheckStatus::Unknown
} else {
CheckStatus::Warn
};
let message = if conventions.is_empty() {
body
} else {
format!(
"conventions-bearing skill(s) have drifted ({}) — a framework-guaranteed rule \
(the commit/PR attribution footer, the Why/What/Test doc pattern) may have \
silently lapsed in the deployed copy. {body}",
summarise(&conventions)
)
};
DoctorCheck::new(CHECK_NAME, status, message)
}
fn drift_parts(drifted: &[(&'static str, String)]) -> Vec<String> {
let mut order: Vec<&'static str> = Vec::new();
for (label, _) in drifted {
if !order.contains(label) {
order.push(label);
}
}
order
.into_iter()
.map(|label| {
let entries: Vec<String> = drifted
.iter()
.filter(|(l, _)| *l == label)
.map(|(_, where_)| where_.clone())
.collect();
format!(
"{} drifted at the {label} tier — {} ({})",
entries.len(),
drift_remedy(label),
summarise(&entries)
)
})
.collect()
}
fn drift_remedy(label: &str) -> &'static str {
match label {
"operator home" => "REPAIRABLE by `tm install`",
"managed config" => {
"`tm install` does NOT write this tier — it is refreshed on managed-session \
bootstrap, or repair it now with `tm doctor --fix-skills --yes`"
}
"project" => {
"`tm install` does NOT write this tier — it is refreshed when a managed session \
is next prepared for this project, or repair it now with `tm doctor --fix-skills --yes`"
}
_ => "repairable with `tm doctor --fix-skills --yes`",
}
}
fn summarise(items: &[String]) -> String {
let shown: Vec<&str> = items.iter().take(MAX_NAMED).map(String::as_str).collect();
if items.len() > shown.len() {
format!(
"{}, … (+{} more)",
shown.join(", "),
items.len() - shown.len()
)
} else {
shown.join(", ")
}
}
#[cfg(test)]
#[path = "doctor_skill_drift_tests.rs"]
mod tests;