use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use runx_contracts::{
DoctorDiagnostic, DoctorDiagnosticSeverity, DoctorLocation, DoctorRepair,
DoctorRepairConfidence, DoctorRepairKind, DoctorRepairRisk, DoctorReport, DoctorReportSchema,
DoctorStatus, DoctorSummary, JsonNumber, JsonObject, JsonValue, sha256_prefixed,
};
use runx_parser::{parse_runner_manifest_yaml, validate_runner_manifest};
use runx_receipts::canonical_stable_json;
use serde::Deserialize;
use crate::RuntimeError;
use crate::filesystem::{read_dir_sorted, read_to_string};
use crate::path_util::{count_yaml_files, lexical_normalize, project_path};
use crate::tool_catalogs::build::hash_tool_source;
const FILE_BUDGETS: &[DoctorFileBudget] = &[
DoctorFileBudget {
path: "packages/cli/src/index.ts",
max_lines: 1000,
},
DoctorFileBudget {
path: "packages/cli/src/commands/doctor.ts",
max_lines: 950,
},
];
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DoctorOptions;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DoctorFileBudget {
path: &'static str,
max_lines: u64,
}
#[derive(Deserialize)]
struct ToolManifestProbe {
source_hash: Option<String>,
}
#[must_use]
pub fn default_doctor_options() -> DoctorOptions {
DoctorOptions
}
pub fn run_doctor(root: &Path, options: &DoctorOptions) -> Result<DoctorReport, RuntimeError> {
let _ = options;
let root = lexical_normalize(root);
let mut diagnostics = Vec::new();
diagnostics.extend(discover_file_budget_diagnostics(&root)?);
diagnostics.extend(discover_cross_package_reach_in_diagnostics(&root)?);
diagnostics.extend(discover_tool_diagnostics(&root)?);
diagnostics.extend(discover_skill_diagnostics(&root)?);
diagnostics.sort_by(|left, right| {
left.location
.path
.cmp(&right.location.path)
.then_with(|| left.id.cmp(&right.id))
});
let summary = summary(&diagnostics);
let status = if summary.errors > 0 {
DoctorStatus::Failure
} else {
DoctorStatus::Success
};
Ok(DoctorReport {
schema: DoctorReportSchema::V1,
status,
summary,
diagnostics,
})
}
fn discover_file_budget_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
let mut diagnostics = Vec::new();
for budget in FILE_BUDGETS {
let file_path = root.join(budget.path);
if !file_path.exists() {
continue;
}
let contents = read_to_string(&file_path)?;
let line_count = count_file_lines(&contents);
if line_count <= budget.max_lines {
continue;
}
let target = object([
("kind", string_value("workspace")),
("ref", string_value(budget.path)),
]);
let location = DoctorLocation {
path: budget.path.to_owned(),
json_pointer: None,
};
let evidence = object([
("line_count", number_value(line_count)),
("max_lines", number_value(budget.max_lines)),
]);
diagnostics.push(create_diagnostic(DiagnosticParts {
id: "runx.structure.file_budget.exceeded",
severity: DoctorDiagnosticSeverity::Error,
title: "File exceeded structural line budget",
message: format!(
"{} is {} lines, above the enforced budget of {}.",
budget.path, line_count, budget.max_lines
),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"split_file_along_real_boundary",
DoctorRepairConfidence::Medium,
DoctorRepairRisk::Low,
false,
)],
})?);
}
Ok(diagnostics)
}
fn discover_cross_package_reach_in_diagnostics(
root: &Path,
) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
let packages_root = root.join("packages");
if !packages_root.exists() {
return Ok(Vec::new());
}
let mut diagnostics = Vec::new();
for entry in list_source_files(&packages_root)? {
let Some(source_package) = workspace_package_name(root, &entry) else {
continue;
};
let contents = read_to_string(&entry)?;
for specifier in extract_import_specifiers(&contents) {
if !specifier.starts_with('.') {
continue;
}
let resolved = lexical_normalize(
&entry
.parent()
.map_or_else(PathBuf::new, Path::to_path_buf)
.join(&specifier),
);
let target_segments = project_segments(root, &resolved);
if target_segments.len() < 3
|| target_segments[0] != "packages"
|| target_segments[2] != "src"
{
continue;
}
let target_package = target_segments[1].clone();
if target_package == source_package {
continue;
}
let source_path = project_path(root, &entry);
let resolved_path = project_path(root, &resolved);
let target = object([
("kind", string_value("workspace")),
("ref", string_value(&source_path)),
]);
let location = DoctorLocation {
path: source_path.clone(),
json_pointer: None,
};
let evidence = object([
("specifier", string_value(&specifier)),
("source_package", string_value(&source_package)),
("target_package", string_value(&target_package)),
("resolved_path", string_value(&resolved_path)),
]);
diagnostics.push(create_diagnostic(DiagnosticParts {
id: "runx.structure.cross_package_reach_in",
severity: DoctorDiagnosticSeverity::Error,
title: "Cross-package src reach-in is forbidden",
message: format!(
"{source_path} imports {specifier}, reaching into packages/{target_package}/src directly."
),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"replace_with_package_boundary_import",
DoctorRepairConfidence::High,
DoctorRepairRisk::Low,
false,
)],
})?);
}
}
Ok(diagnostics)
}
fn discover_tool_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
let tools_root = root.join("tools");
let mut diagnostics = Vec::new();
for namespace_entry in read_dir_sorted(&tools_root)? {
if !namespace_entry.is_dir {
continue;
}
for tool_entry in read_dir_sorted(&namespace_entry.path)? {
if !tool_entry.is_dir {
continue;
}
let tool_dir = tool_entry.path;
let tool_ref = format!("{}.{}", namespace_entry.name, tool_entry.name);
let removed_format_path = tool_dir.join("tool.yaml");
if removed_format_path.exists() {
diagnostics.push(removed_tool_yaml_diagnostic(
root,
&tool_ref,
&removed_format_path,
)?);
}
let manifest_path = tool_dir.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let manifest_contents = read_to_string(&manifest_path)?;
let manifest = serde_json::from_str::<ToolManifestProbe>(&manifest_contents).map_err(
|source| {
RuntimeError::json(
format!(
"reading tool manifest {}",
project_path(root, &manifest_path)
),
source,
)
},
)?;
if let Some(source_hash) = &manifest.source_hash {
let actual_source_hash = hash_tool_source(&tool_dir).map_err(|source| {
RuntimeError::effect_state("checking tool manifest source hash", source)
})?;
if source_hash != &actual_source_hash {
diagnostics.push(tool_manifest_stale_diagnostic(
root,
&tool_ref,
&manifest_path,
&tool_dir,
&actual_source_hash,
source_hash,
)?);
}
}
let fixture_count = count_yaml_files(&tool_dir.join("fixtures"))?;
if fixture_count == 0 {
diagnostics.push(tool_fixture_missing_diagnostic(
root,
&tool_ref,
&manifest_path,
&tool_dir.join("fixtures"),
fixture_count,
)?);
}
}
}
Ok(diagnostics)
}
fn removed_tool_yaml_diagnostic(
root: &Path,
tool_ref: &str,
removed_format_path: &Path,
) -> Result<DoctorDiagnostic, RuntimeError> {
let location_path = project_path(root, removed_format_path);
let expected_manifest =
project_path(root, &removed_format_path.with_file_name("manifest.json"));
let target = object([
("kind", string_value("tool")),
("ref", string_value(tool_ref)),
]);
let location = DoctorLocation {
path: location_path.clone(),
json_pointer: None,
};
let evidence = object([("expected_manifest", string_value(&expected_manifest))]);
create_diagnostic(DiagnosticParts {
id: "runx.tool.manifest.removed_format",
severity: DoctorDiagnosticSeverity::Error,
title: "tool.yaml is no longer supported",
message: format!("Tool {tool_ref} still uses tool.yaml. Runx resolves manifest.json only."),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"replace_removed_tool_manifest",
DoctorRepairConfidence::High,
DoctorRepairRisk::Medium,
true,
)],
})
}
fn tool_fixture_missing_diagnostic(
root: &Path,
tool_ref: &str,
manifest_path: &Path,
fixtures_path: &Path,
fixture_count: u64,
) -> Result<DoctorDiagnostic, RuntimeError> {
let location_path = project_path(root, manifest_path);
let expected_location = project_path(root, fixtures_path);
let target = object([
("kind", string_value("tool")),
("ref", string_value(tool_ref)),
]);
let location = DoctorLocation {
path: location_path.clone(),
json_pointer: None,
};
let evidence = object([
("fixture_count", number_value(fixture_count)),
("expected_location", string_value(&expected_location)),
]);
create_diagnostic(DiagnosticParts {
id: "runx.tool.fixture.missing",
severity: DoctorDiagnosticSeverity::Error,
title: "Tool has no deterministic fixture",
message: format!("Tool {tool_ref} declares a manifest but has no deterministic fixture."),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"add_tool_fixture",
DoctorRepairConfidence::Medium,
DoctorRepairRisk::Low,
false,
)],
})
}
fn tool_manifest_stale_diagnostic(
root: &Path,
tool_ref: &str,
manifest_path: &Path,
tool_dir: &Path,
expected_hash: &str,
actual_hash: &str,
) -> Result<DoctorDiagnostic, RuntimeError> {
let location_path = project_path(root, manifest_path);
let tool_path = project_path(root, tool_dir);
let target = object([
("kind", string_value("tool")),
("ref", string_value(tool_ref)),
]);
let location = DoctorLocation {
path: location_path.clone(),
json_pointer: Some("/source_hash".to_owned()),
};
let evidence = object([
("expected", string_value(expected_hash)),
("actual", string_value(actual_hash)),
]);
create_diagnostic(DiagnosticParts {
id: "runx.tool.manifest.stale",
severity: DoctorDiagnosticSeverity::Error,
title: "Tool manifest is stale",
message: format!("Tool {tool_ref} source_hash does not match current source files."),
target,
location,
evidence: Some(evidence),
repairs: vec![run_command_repair(
"rebuild_tool_manifest",
format!("runx tool build {tool_path}"),
DoctorRepairConfidence::High,
DoctorRepairRisk::Low,
false,
)],
})
}
fn discover_skill_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
let mut diagnostics = Vec::new();
for profile_path in discover_skill_profile_paths(root)? {
let contents = read_to_string(&profile_path)?;
if !contents.contains("runners:") {
continue;
}
let skill_dir = profile_path.parent().map_or(root, |parent| parent);
let skill_name = skill_dir.file_name().map_or_else(
|| ".".to_owned(),
|name| name.to_string_lossy().into_owned(),
);
if let Err(message) = validate_skill_profile(&contents) {
diagnostics.push(skill_profile_invalid_diagnostic(
root,
&profile_path,
&skill_name,
&message,
)?);
continue;
}
let fixture_count = count_yaml_files(&skill_dir.join("fixtures"))?;
let harness_case_count = inline_harness_case_count(&contents);
if fixture_count == 0 && harness_case_count == 0 {
diagnostics.push(skill_fixture_missing_diagnostic(
root,
&profile_path,
&skill_name,
fixture_count,
harness_case_count,
)?);
}
}
Ok(diagnostics)
}
fn validate_skill_profile(contents: &str) -> Result<(), String> {
let raw = parse_runner_manifest_yaml(contents).map_err(|error| error.to_string())?;
validate_runner_manifest(raw).map_err(|error| error.to_string())?;
Ok(())
}
fn skill_fixture_missing_diagnostic(
root: &Path,
profile_path: &Path,
skill_name: &str,
fixture_count: u64,
harness_case_count: u64,
) -> Result<DoctorDiagnostic, RuntimeError> {
let location_path = project_path(root, profile_path);
let target = object([
("kind", string_value("skill")),
("ref", string_value(skill_name)),
]);
let location = DoctorLocation {
path: location_path.clone(),
json_pointer: Some("/harness".to_owned()),
};
let evidence = object([
("fixture_count", number_value(fixture_count)),
("harness_case_count", number_value(harness_case_count)),
]);
create_diagnostic(DiagnosticParts {
id: "runx.skill.fixture.missing",
severity: DoctorDiagnosticSeverity::Error,
title: "Skill has no harness coverage",
message: format!(
"Skill {skill_name} declares an execution profile but has no fixtures or inline harness.cases."
),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"add_inline_harness_case",
DoctorRepairConfidence::Medium,
DoctorRepairRisk::Low,
false,
)],
})
}
fn skill_profile_invalid_diagnostic(
root: &Path,
profile_path: &Path,
skill_name: &str,
message: &str,
) -> Result<DoctorDiagnostic, RuntimeError> {
let location_path = project_path(root, profile_path);
let target = object([
("kind", string_value("skill")),
("ref", string_value(skill_name)),
]);
let location = DoctorLocation {
path: location_path.clone(),
json_pointer: Some("/runners".to_owned()),
};
let evidence = object([("error", string_value(message))]);
create_diagnostic(DiagnosticParts {
id: "runx.skill.profile.invalid",
severity: DoctorDiagnosticSeverity::Error,
title: "Skill execution profile is invalid",
message: format!("Skill {skill_name} has an invalid execution profile: {message}"),
target,
location,
evidence: Some(evidence),
repairs: vec![manual_repair(
"fix_execution_profile",
DoctorRepairConfidence::High,
DoctorRepairRisk::Low,
true,
)],
})
}
struct DiagnosticParts {
id: &'static str,
severity: DoctorDiagnosticSeverity,
title: &'static str,
message: String,
target: JsonObject,
location: DoctorLocation,
evidence: Option<JsonObject>,
repairs: Vec<DoctorRepair>,
}
fn create_diagnostic(parts: DiagnosticParts) -> Result<DoctorDiagnostic, RuntimeError> {
let instance_id = diagnostic_instance_id(
parts.id,
&parts.target,
&parts.location,
parts.evidence.as_ref(),
)?;
Ok(DoctorDiagnostic {
id: parts.id.to_owned(),
instance_id,
severity: parts.severity,
title: parts.title.to_owned(),
message: parts.message,
target: parts.target,
location: parts.location,
evidence: parts.evidence,
repairs: parts.repairs,
})
}
fn diagnostic_instance_id(
id: &str,
target: &JsonObject,
location: &DoctorLocation,
evidence: Option<&JsonObject>,
) -> Result<String, RuntimeError> {
let mut material: JsonObject = BTreeMap::new();
material.insert("id".to_owned(), JsonValue::String(id.to_owned()));
material.insert("target".to_owned(), JsonValue::Object(target.clone()));
material.insert(
"location".to_owned(),
JsonValue::Object(location_object(location)),
);
if let Some(evidence) = evidence {
material.insert("evidence".to_owned(), JsonValue::Object(evidence.clone()));
}
let canonical = canonical_stable_json(&JsonValue::Object(material)).map_err(|source| {
RuntimeError::effect_state("canonicalizing doctor hash material", source)
})?;
Ok(sha256_prefixed(canonical.as_bytes()))
}
fn location_object(location: &DoctorLocation) -> JsonObject {
let mut object: JsonObject = BTreeMap::new();
object.insert("path".to_owned(), JsonValue::String(location.path.clone()));
if let Some(json_pointer) = &location.json_pointer {
object.insert(
"json_pointer".to_owned(),
JsonValue::String(json_pointer.clone()),
);
}
object
}
fn manual_repair(
id: &str,
confidence: DoctorRepairConfidence,
risk: DoctorRepairRisk,
requires_human_review: bool,
) -> DoctorRepair {
DoctorRepair {
id: id.to_owned(),
kind: DoctorRepairKind::Manual,
confidence,
risk,
path: None,
json_pointer: None,
contents: None,
patch: None,
command: None,
requires_human_review,
}
}
fn run_command_repair(
id: &str,
command: String,
confidence: DoctorRepairConfidence,
risk: DoctorRepairRisk,
requires_human_review: bool,
) -> DoctorRepair {
DoctorRepair {
id: id.to_owned(),
kind: DoctorRepairKind::RunCommand,
confidence,
risk,
path: None,
json_pointer: None,
contents: None,
patch: None,
command: Some(command),
requires_human_review,
}
}
fn summary(diagnostics: &[DoctorDiagnostic]) -> DoctorSummary {
let mut errors = 0;
let mut warnings = 0;
let mut infos = 0;
for diagnostic in diagnostics {
match diagnostic.severity {
DoctorDiagnosticSeverity::Error => errors += 1,
DoctorDiagnosticSeverity::Warning => warnings += 1,
DoctorDiagnosticSeverity::Info => infos += 1,
}
}
DoctorSummary {
errors,
warnings,
infos,
}
}
fn discover_skill_profile_paths(root: &Path) -> Result<Vec<PathBuf>, RuntimeError> {
let mut paths = Vec::new();
let root_profile = root.join("X.yaml");
if root_profile.exists() {
paths.push(root_profile);
}
for skill_entry in read_dir_sorted(&root.join("skills"))? {
if !skill_entry.is_dir {
continue;
}
let profile_path = skill_entry.path.join("X.yaml");
if profile_path.exists() {
paths.push(profile_path);
}
}
paths.sort();
Ok(paths)
}
fn inline_harness_case_count(contents: &str) -> u64 {
if contents.contains("harness:") && contents.contains("cases:") {
1
} else {
0
}
}
fn list_source_files(directory: &Path) -> Result<Vec<PathBuf>, RuntimeError> {
let mut files = Vec::new();
for entry in read_dir_sorted(directory)? {
if entry.name == "dist" || entry.name == "node_modules" {
continue;
}
if entry.is_dir {
files.extend(list_source_files(&entry.path)?);
} else if entry.is_file && is_source_path(&entry.path) {
files.push(entry.path);
}
}
files.sort();
Ok(files)
}
fn is_source_path(path: &Path) -> bool {
path.extension()
.map(|extension| {
matches!(
extension.to_string_lossy().as_ref(),
"ts" | "tsx" | "js" | "jsx" | "mts" | "mjs" | "cts" | "cjs"
)
})
.unwrap_or(false)
}
fn extract_import_specifiers(contents: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in contents.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with("import ") && !trimmed.starts_with("export ") {
continue;
}
for quote in ['"', '\''] {
let Some(start) = trimmed.find(quote) else {
continue;
};
let rest = &trimmed[start + quote.len_utf8()..];
let Some(end) = rest.find(quote) else {
continue;
};
let specifier = rest[..end].to_owned();
if !specifiers.contains(&specifier) {
specifiers.push(specifier);
}
}
}
specifiers
}
fn count_file_lines(contents: &str) -> u64 {
if contents.is_empty() {
0
} else {
contents.bytes().filter(|byte| *byte == b'\n').count() as u64
}
}
fn workspace_package_name(root: &Path, file_path: &Path) -> Option<String> {
let segments = project_segments(root, file_path);
if segments
.first()
.is_some_and(|segment| segment == "packages")
{
segments.get(1).cloned()
} else {
None
}
}
fn project_segments(root: &Path, path: &Path) -> Vec<String> {
project_path(root, path)
.split('/')
.filter(|segment| !segment.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn object(entries: impl IntoIterator<Item = (&'static str, JsonValue)>) -> JsonObject {
BTreeMap::from_iter(
entries
.into_iter()
.map(|(key, value)| (key.to_owned(), value)),
)
}
fn string_value(value: &str) -> JsonValue {
JsonValue::String(value.to_owned())
}
fn number_value(value: u64) -> JsonValue {
JsonValue::Number(JsonNumber::U64(value))
}
#[cfg(test)]
mod tests {
use super::validate_skill_profile;
const VALID_PROFILE: &str = r#"
runners:
main:
default: true
type: agent-task
agent: builder
task: probe
outputs:
result: string
inputs:
objective:
type: string
required: true
description: "x"
harness:
cases:
- name: ok
inputs:
objective: x
caller:
answers:
agent_task.probe.output:
result: ok
expect:
status: sealed
receipt:
schema: runx.receipt.v1
state: sealed
disposition: closed
"#;
const INVALID_HARNESS_STATUS_PROFILE: &str = r#"
runners:
main:
default: true
type: agent-task
agent: builder
task: probe
outputs:
result: string
inputs:
objective:
type: string
required: true
description: "x"
harness:
cases:
- name: bad
inputs:
objective: x
caller:
answers:
agent_task.probe.output:
result: ok
expect:
status: success
receipt:
schema: runx.receipt.v1
state: sealed
disposition: closed
"#;
#[test]
fn valid_execution_profile_passes() {
assert!(validate_skill_profile(VALID_PROFILE).is_ok());
}
#[test]
fn invalid_harness_status_is_rejected() {
let result = validate_skill_profile(INVALID_HARNESS_STATUS_PROFILE);
assert!(
result.is_err(),
"an invalid harness expect.status must be rejected by doctor"
);
if let Err(message) = result {
assert!(
message.contains("must be sealed"),
"unexpected error message: {message}"
);
}
}
}