Skip to main content

runx_runtime/
doctor.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use runx_contracts::{
5    DoctorDiagnostic, DoctorDiagnosticSeverity, DoctorLocation, DoctorRepair,
6    DoctorRepairConfidence, DoctorRepairKind, DoctorRepairRisk, DoctorReport, DoctorReportSchema,
7    DoctorStatus, DoctorSummary, JsonNumber, JsonObject, JsonValue, sha256_prefixed,
8};
9use runx_parser::{parse_runner_manifest_yaml, validate_runner_manifest};
10use runx_receipts::canonical_stable_json;
11use serde::Deserialize;
12
13use crate::RuntimeError;
14use crate::filesystem::{read_dir_sorted, read_to_string};
15use crate::path_util::{count_yaml_files, lexical_normalize, project_path};
16use crate::tool_catalogs::build::hash_tool_source;
17
18// rust-style-allow: large-file - this first doctor slice keeps parity checks and builders together until follow-up diagnostics add natural module boundaries.
19
20const FILE_BUDGETS: &[DoctorFileBudget] = &[
21    DoctorFileBudget {
22        path: "packages/cli/src/index.ts",
23        max_lines: 1000,
24    },
25    DoctorFileBudget {
26        path: "packages/cli/src/commands/doctor.ts",
27        max_lines: 950,
28    },
29];
30
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct DoctorOptions;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35struct DoctorFileBudget {
36    path: &'static str,
37    max_lines: u64,
38}
39
40#[derive(Deserialize)]
41struct ToolManifestProbe {
42    source_hash: Option<String>,
43}
44
45#[must_use]
46pub fn default_doctor_options() -> DoctorOptions {
47    DoctorOptions
48}
49
50pub fn run_doctor(root: &Path, options: &DoctorOptions) -> Result<DoctorReport, RuntimeError> {
51    let _ = options;
52    let root = lexical_normalize(root);
53
54    let mut diagnostics = Vec::new();
55    diagnostics.extend(discover_file_budget_diagnostics(&root)?);
56    diagnostics.extend(discover_cross_package_reach_in_diagnostics(&root)?);
57    diagnostics.extend(discover_tool_diagnostics(&root)?);
58    diagnostics.extend(discover_skill_diagnostics(&root)?);
59    diagnostics.sort_by(|left, right| {
60        left.location
61            .path
62            .cmp(&right.location.path)
63            .then_with(|| left.id.cmp(&right.id))
64    });
65
66    let summary = summary(&diagnostics);
67    let status = if summary.errors > 0 {
68        DoctorStatus::Failure
69    } else {
70        DoctorStatus::Success
71    };
72    Ok(DoctorReport {
73        schema: DoctorReportSchema::V1,
74        status,
75        summary,
76        diagnostics,
77    })
78}
79
80fn discover_file_budget_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
81    let mut diagnostics = Vec::new();
82    for budget in FILE_BUDGETS {
83        let file_path = root.join(budget.path);
84        if !file_path.exists() {
85            continue;
86        }
87        let contents = read_to_string(&file_path)?;
88        let line_count = count_file_lines(&contents);
89        if line_count <= budget.max_lines {
90            continue;
91        }
92        let target = object([
93            ("kind", string_value("workspace")),
94            ("ref", string_value(budget.path)),
95        ]);
96        let location = DoctorLocation {
97            path: budget.path.to_owned(),
98            json_pointer: None,
99        };
100        let evidence = object([
101            ("line_count", number_value(line_count)),
102            ("max_lines", number_value(budget.max_lines)),
103        ]);
104        diagnostics.push(create_diagnostic(DiagnosticParts {
105            id: "runx.structure.file_budget.exceeded",
106            severity: DoctorDiagnosticSeverity::Error,
107            title: "File exceeded structural line budget",
108            message: format!(
109                "{} is {} lines, above the enforced budget of {}.",
110                budget.path, line_count, budget.max_lines
111            ),
112            target,
113            location,
114            evidence: Some(evidence),
115            repairs: vec![manual_repair(
116                "split_file_along_real_boundary",
117                DoctorRepairConfidence::Medium,
118                DoctorRepairRisk::Low,
119                false,
120            )],
121        })?);
122    }
123    Ok(diagnostics)
124}
125
126// rust-style-allow: long-function - cross-package reach-in parity mirrors the TypeScript scanner in one read-only pass.
127fn discover_cross_package_reach_in_diagnostics(
128    root: &Path,
129) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
130    let packages_root = root.join("packages");
131    if !packages_root.exists() {
132        return Ok(Vec::new());
133    }
134
135    let mut diagnostics = Vec::new();
136    for entry in list_source_files(&packages_root)? {
137        let Some(source_package) = workspace_package_name(root, &entry) else {
138            continue;
139        };
140        let contents = read_to_string(&entry)?;
141        for specifier in extract_import_specifiers(&contents) {
142            if !specifier.starts_with('.') {
143                continue;
144            }
145            let resolved = lexical_normalize(
146                &entry
147                    .parent()
148                    .map_or_else(PathBuf::new, Path::to_path_buf)
149                    .join(&specifier),
150            );
151            let target_segments = project_segments(root, &resolved);
152            if target_segments.len() < 3
153                || target_segments[0] != "packages"
154                || target_segments[2] != "src"
155            {
156                continue;
157            }
158            let target_package = target_segments[1].clone();
159            if target_package == source_package {
160                continue;
161            }
162
163            let source_path = project_path(root, &entry);
164            let resolved_path = project_path(root, &resolved);
165            let target = object([
166                ("kind", string_value("workspace")),
167                ("ref", string_value(&source_path)),
168            ]);
169            let location = DoctorLocation {
170                path: source_path.clone(),
171                json_pointer: None,
172            };
173            let evidence = object([
174                ("specifier", string_value(&specifier)),
175                ("source_package", string_value(&source_package)),
176                ("target_package", string_value(&target_package)),
177                ("resolved_path", string_value(&resolved_path)),
178            ]);
179            diagnostics.push(create_diagnostic(DiagnosticParts {
180                id: "runx.structure.cross_package_reach_in",
181                severity: DoctorDiagnosticSeverity::Error,
182                title: "Cross-package src reach-in is forbidden",
183                message: format!(
184                    "{source_path} imports {specifier}, reaching into packages/{target_package}/src directly."
185                ),
186                target,
187                location,
188                evidence: Some(evidence),
189                repairs: vec![manual_repair(
190                    "replace_with_package_boundary_import",
191                    DoctorRepairConfidence::High,
192                    DoctorRepairRisk::Low,
193                    false,
194                )],
195            })?);
196        }
197    }
198    Ok(diagnostics)
199}
200
201// rust-style-allow: long-function - tool diagnostics keep manifest, fixture, and
202// generated repair evidence in one read-only pass.
203fn discover_tool_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
204    let tools_root = root.join("tools");
205    let mut diagnostics = Vec::new();
206    for namespace_entry in read_dir_sorted(&tools_root)? {
207        if !namespace_entry.is_dir {
208            continue;
209        }
210        for tool_entry in read_dir_sorted(&namespace_entry.path)? {
211            if !tool_entry.is_dir {
212                continue;
213            }
214            let tool_dir = tool_entry.path;
215            let tool_ref = format!("{}.{}", namespace_entry.name, tool_entry.name);
216            let removed_format_path = tool_dir.join("tool.yaml");
217            if removed_format_path.exists() {
218                diagnostics.push(removed_tool_yaml_diagnostic(
219                    root,
220                    &tool_ref,
221                    &removed_format_path,
222                )?);
223            }
224
225            let manifest_path = tool_dir.join("manifest.json");
226            if !manifest_path.exists() {
227                continue;
228            }
229            let manifest_contents = read_to_string(&manifest_path)?;
230            let manifest = serde_json::from_str::<ToolManifestProbe>(&manifest_contents).map_err(
231                |source| {
232                    RuntimeError::json(
233                        format!(
234                            "reading tool manifest {}",
235                            project_path(root, &manifest_path)
236                        ),
237                        source,
238                    )
239                },
240            )?;
241            if let Some(source_hash) = &manifest.source_hash {
242                let actual_source_hash = hash_tool_source(&tool_dir).map_err(|source| {
243                    RuntimeError::effect_state("checking tool manifest source hash", source)
244                })?;
245                if source_hash != &actual_source_hash {
246                    diagnostics.push(tool_manifest_stale_diagnostic(
247                        root,
248                        &tool_ref,
249                        &manifest_path,
250                        &tool_dir,
251                        &actual_source_hash,
252                        source_hash,
253                    )?);
254                }
255            }
256            let fixture_count = count_yaml_files(&tool_dir.join("fixtures"))?;
257            if fixture_count == 0 {
258                diagnostics.push(tool_fixture_missing_diagnostic(
259                    root,
260                    &tool_ref,
261                    &manifest_path,
262                    &tool_dir.join("fixtures"),
263                    fixture_count,
264                )?);
265            }
266        }
267    }
268    Ok(diagnostics)
269}
270
271fn removed_tool_yaml_diagnostic(
272    root: &Path,
273    tool_ref: &str,
274    removed_format_path: &Path,
275) -> Result<DoctorDiagnostic, RuntimeError> {
276    let location_path = project_path(root, removed_format_path);
277    let expected_manifest =
278        project_path(root, &removed_format_path.with_file_name("manifest.json"));
279    let target = object([
280        ("kind", string_value("tool")),
281        ("ref", string_value(tool_ref)),
282    ]);
283    let location = DoctorLocation {
284        path: location_path.clone(),
285        json_pointer: None,
286    };
287    let evidence = object([("expected_manifest", string_value(&expected_manifest))]);
288    create_diagnostic(DiagnosticParts {
289        id: "runx.tool.manifest.removed_format",
290        severity: DoctorDiagnosticSeverity::Error,
291        title: "tool.yaml is no longer supported",
292        message: format!("Tool {tool_ref} still uses tool.yaml. Runx resolves manifest.json only."),
293        target,
294        location,
295        evidence: Some(evidence),
296        repairs: vec![manual_repair(
297            "replace_removed_tool_manifest",
298            DoctorRepairConfidence::High,
299            DoctorRepairRisk::Medium,
300            true,
301        )],
302    })
303}
304
305fn tool_fixture_missing_diagnostic(
306    root: &Path,
307    tool_ref: &str,
308    manifest_path: &Path,
309    fixtures_path: &Path,
310    fixture_count: u64,
311) -> Result<DoctorDiagnostic, RuntimeError> {
312    let location_path = project_path(root, manifest_path);
313    let expected_location = project_path(root, fixtures_path);
314    let target = object([
315        ("kind", string_value("tool")),
316        ("ref", string_value(tool_ref)),
317    ]);
318    let location = DoctorLocation {
319        path: location_path.clone(),
320        json_pointer: None,
321    };
322    let evidence = object([
323        ("fixture_count", number_value(fixture_count)),
324        ("expected_location", string_value(&expected_location)),
325    ]);
326    create_diagnostic(DiagnosticParts {
327        id: "runx.tool.fixture.missing",
328        severity: DoctorDiagnosticSeverity::Error,
329        title: "Tool has no deterministic fixture",
330        message: format!("Tool {tool_ref} declares a manifest but has no deterministic fixture."),
331        target,
332        location,
333        evidence: Some(evidence),
334        repairs: vec![manual_repair(
335            "add_tool_fixture",
336            DoctorRepairConfidence::Medium,
337            DoctorRepairRisk::Low,
338            false,
339        )],
340    })
341}
342
343fn tool_manifest_stale_diagnostic(
344    root: &Path,
345    tool_ref: &str,
346    manifest_path: &Path,
347    tool_dir: &Path,
348    expected_hash: &str,
349    actual_hash: &str,
350) -> Result<DoctorDiagnostic, RuntimeError> {
351    let location_path = project_path(root, manifest_path);
352    let tool_path = project_path(root, tool_dir);
353    let target = object([
354        ("kind", string_value("tool")),
355        ("ref", string_value(tool_ref)),
356    ]);
357    let location = DoctorLocation {
358        path: location_path.clone(),
359        json_pointer: Some("/source_hash".to_owned()),
360    };
361    let evidence = object([
362        ("expected", string_value(expected_hash)),
363        ("actual", string_value(actual_hash)),
364    ]);
365    create_diagnostic(DiagnosticParts {
366        id: "runx.tool.manifest.stale",
367        severity: DoctorDiagnosticSeverity::Error,
368        title: "Tool manifest is stale",
369        message: format!("Tool {tool_ref} source_hash does not match current source files."),
370        target,
371        location,
372        evidence: Some(evidence),
373        repairs: vec![run_command_repair(
374            "rebuild_tool_manifest",
375            format!("runx tool build {tool_path}"),
376            DoctorRepairConfidence::High,
377            DoctorRepairRisk::Low,
378            false,
379        )],
380    })
381}
382
383fn discover_skill_diagnostics(root: &Path) -> Result<Vec<DoctorDiagnostic>, RuntimeError> {
384    let mut diagnostics = Vec::new();
385    for profile_path in discover_skill_profile_paths(root)? {
386        let contents = read_to_string(&profile_path)?;
387        if !contents.contains("runners:") {
388            continue;
389        }
390        let skill_dir = profile_path.parent().map_or(root, |parent| parent);
391        let skill_name = skill_dir.file_name().map_or_else(
392            || ".".to_owned(),
393            |name| name.to_string_lossy().into_owned(),
394        );
395        if let Err(message) = validate_skill_profile(&contents) {
396            diagnostics.push(skill_profile_invalid_diagnostic(
397                root,
398                &profile_path,
399                &skill_name,
400                &message,
401            )?);
402            continue;
403        }
404        let fixture_count = count_yaml_files(&skill_dir.join("fixtures"))?;
405        let harness_case_count = inline_harness_case_count(&contents);
406        if fixture_count == 0 && harness_case_count == 0 {
407            diagnostics.push(skill_fixture_missing_diagnostic(
408                root,
409                &profile_path,
410                &skill_name,
411                fixture_count,
412                harness_case_count,
413            )?);
414        }
415    }
416    Ok(diagnostics)
417}
418
419/// Parse and validate a skill execution profile (X.yaml) the same way the
420/// publish path does, so doctor catches an invalid harness status, an unknown
421/// runner shape, or malformed YAML before publish rather than at publish time.
422fn validate_skill_profile(contents: &str) -> Result<(), String> {
423    let raw = parse_runner_manifest_yaml(contents).map_err(|error| error.to_string())?;
424    validate_runner_manifest(raw).map_err(|error| error.to_string())?;
425    Ok(())
426}
427
428fn skill_fixture_missing_diagnostic(
429    root: &Path,
430    profile_path: &Path,
431    skill_name: &str,
432    fixture_count: u64,
433    harness_case_count: u64,
434) -> Result<DoctorDiagnostic, RuntimeError> {
435    let location_path = project_path(root, profile_path);
436    let target = object([
437        ("kind", string_value("skill")),
438        ("ref", string_value(skill_name)),
439    ]);
440    let location = DoctorLocation {
441        path: location_path.clone(),
442        json_pointer: Some("/harness".to_owned()),
443    };
444    let evidence = object([
445        ("fixture_count", number_value(fixture_count)),
446        ("harness_case_count", number_value(harness_case_count)),
447    ]);
448    create_diagnostic(DiagnosticParts {
449        id: "runx.skill.fixture.missing",
450        severity: DoctorDiagnosticSeverity::Error,
451        title: "Skill has no harness coverage",
452        message: format!(
453            "Skill {skill_name} declares an execution profile but has no fixtures or inline harness.cases."
454        ),
455        target,
456        location,
457        evidence: Some(evidence),
458        repairs: vec![manual_repair(
459            "add_inline_harness_case",
460            DoctorRepairConfidence::Medium,
461            DoctorRepairRisk::Low,
462            false,
463        )],
464    })
465}
466
467fn skill_profile_invalid_diagnostic(
468    root: &Path,
469    profile_path: &Path,
470    skill_name: &str,
471    message: &str,
472) -> Result<DoctorDiagnostic, RuntimeError> {
473    let location_path = project_path(root, profile_path);
474    let target = object([
475        ("kind", string_value("skill")),
476        ("ref", string_value(skill_name)),
477    ]);
478    let location = DoctorLocation {
479        path: location_path.clone(),
480        json_pointer: Some("/runners".to_owned()),
481    };
482    let evidence = object([("error", string_value(message))]);
483    create_diagnostic(DiagnosticParts {
484        id: "runx.skill.profile.invalid",
485        severity: DoctorDiagnosticSeverity::Error,
486        title: "Skill execution profile is invalid",
487        message: format!("Skill {skill_name} has an invalid execution profile: {message}"),
488        target,
489        location,
490        evidence: Some(evidence),
491        repairs: vec![manual_repair(
492            "fix_execution_profile",
493            DoctorRepairConfidence::High,
494            DoctorRepairRisk::Low,
495            true,
496        )],
497    })
498}
499
500struct DiagnosticParts {
501    id: &'static str,
502    severity: DoctorDiagnosticSeverity,
503    title: &'static str,
504    message: String,
505    target: JsonObject,
506    location: DoctorLocation,
507    evidence: Option<JsonObject>,
508    repairs: Vec<DoctorRepair>,
509}
510
511fn create_diagnostic(parts: DiagnosticParts) -> Result<DoctorDiagnostic, RuntimeError> {
512    let instance_id = diagnostic_instance_id(
513        parts.id,
514        &parts.target,
515        &parts.location,
516        parts.evidence.as_ref(),
517    )?;
518    Ok(DoctorDiagnostic {
519        id: parts.id.to_owned(),
520        instance_id,
521        severity: parts.severity,
522        title: parts.title.to_owned(),
523        message: parts.message,
524        target: parts.target,
525        location: parts.location,
526        evidence: parts.evidence,
527        repairs: parts.repairs,
528    })
529}
530
531/// Canonical, order-independent identity hash of a diagnostic.
532///
533/// The hash material is the single typed source of truth (`target`,
534/// `location`, `evidence`) assembled into a `JsonObject` (a `BTreeMap`, sorted
535/// at every level) and rendered through the shared `runx.stable-json.v1`
536/// canonical writer. The TypeScript doctor mirrors this exactly via
537/// `canonicalJsonStringify({id, target, location, evidence})`, so both
538/// languages produce byte-identical canonical JSON and identical ids.
539fn diagnostic_instance_id(
540    id: &str,
541    target: &JsonObject,
542    location: &DoctorLocation,
543    evidence: Option<&JsonObject>,
544) -> Result<String, RuntimeError> {
545    let mut material: JsonObject = BTreeMap::new();
546    material.insert("id".to_owned(), JsonValue::String(id.to_owned()));
547    material.insert("target".to_owned(), JsonValue::Object(target.clone()));
548    material.insert(
549        "location".to_owned(),
550        JsonValue::Object(location_object(location)),
551    );
552    if let Some(evidence) = evidence {
553        material.insert("evidence".to_owned(), JsonValue::Object(evidence.clone()));
554    }
555    let canonical = canonical_stable_json(&JsonValue::Object(material)).map_err(|source| {
556        RuntimeError::effect_state("canonicalizing doctor hash material", source)
557    })?;
558    Ok(sha256_prefixed(canonical.as_bytes()))
559}
560
561/// Convert a typed `DoctorLocation` into its canonical-hash object form,
562/// omitting `json_pointer` when absent so the hash material matches the
563/// serialized wire shape (which skips `None`).
564fn location_object(location: &DoctorLocation) -> JsonObject {
565    let mut object: JsonObject = BTreeMap::new();
566    object.insert("path".to_owned(), JsonValue::String(location.path.clone()));
567    if let Some(json_pointer) = &location.json_pointer {
568        object.insert(
569            "json_pointer".to_owned(),
570            JsonValue::String(json_pointer.clone()),
571        );
572    }
573    object
574}
575
576fn manual_repair(
577    id: &str,
578    confidence: DoctorRepairConfidence,
579    risk: DoctorRepairRisk,
580    requires_human_review: bool,
581) -> DoctorRepair {
582    DoctorRepair {
583        id: id.to_owned(),
584        kind: DoctorRepairKind::Manual,
585        confidence,
586        risk,
587        path: None,
588        json_pointer: None,
589        contents: None,
590        patch: None,
591        command: None,
592        requires_human_review,
593    }
594}
595
596fn run_command_repair(
597    id: &str,
598    command: String,
599    confidence: DoctorRepairConfidence,
600    risk: DoctorRepairRisk,
601    requires_human_review: bool,
602) -> DoctorRepair {
603    DoctorRepair {
604        id: id.to_owned(),
605        kind: DoctorRepairKind::RunCommand,
606        confidence,
607        risk,
608        path: None,
609        json_pointer: None,
610        contents: None,
611        patch: None,
612        command: Some(command),
613        requires_human_review,
614    }
615}
616
617fn summary(diagnostics: &[DoctorDiagnostic]) -> DoctorSummary {
618    let mut errors = 0;
619    let mut warnings = 0;
620    let mut infos = 0;
621    for diagnostic in diagnostics {
622        match diagnostic.severity {
623            DoctorDiagnosticSeverity::Error => errors += 1,
624            DoctorDiagnosticSeverity::Warning => warnings += 1,
625            DoctorDiagnosticSeverity::Info => infos += 1,
626        }
627    }
628    DoctorSummary {
629        errors,
630        warnings,
631        infos,
632    }
633}
634
635fn discover_skill_profile_paths(root: &Path) -> Result<Vec<PathBuf>, RuntimeError> {
636    let mut paths = Vec::new();
637    let root_profile = root.join("X.yaml");
638    if root_profile.exists() {
639        paths.push(root_profile);
640    }
641    for skill_entry in read_dir_sorted(&root.join("skills"))? {
642        if !skill_entry.is_dir {
643            continue;
644        }
645        let profile_path = skill_entry.path.join("X.yaml");
646        if profile_path.exists() {
647            paths.push(profile_path);
648        }
649    }
650    paths.sort();
651    Ok(paths)
652}
653
654fn inline_harness_case_count(contents: &str) -> u64 {
655    if contents.contains("harness:") && contents.contains("cases:") {
656        1
657    } else {
658        0
659    }
660}
661
662fn list_source_files(directory: &Path) -> Result<Vec<PathBuf>, RuntimeError> {
663    let mut files = Vec::new();
664    for entry in read_dir_sorted(directory)? {
665        if entry.name == "dist" || entry.name == "node_modules" {
666            continue;
667        }
668        if entry.is_dir {
669            files.extend(list_source_files(&entry.path)?);
670        } else if entry.is_file && is_source_path(&entry.path) {
671            files.push(entry.path);
672        }
673    }
674    files.sort();
675    Ok(files)
676}
677
678fn is_source_path(path: &Path) -> bool {
679    path.extension()
680        .map(|extension| {
681            matches!(
682                extension.to_string_lossy().as_ref(),
683                "ts" | "tsx" | "js" | "jsx" | "mts" | "mjs" | "cts" | "cjs"
684            )
685        })
686        .unwrap_or(false)
687}
688
689fn extract_import_specifiers(contents: &str) -> Vec<String> {
690    let mut specifiers = Vec::new();
691    for line in contents.lines() {
692        let trimmed = line.trim_start();
693        if !trimmed.starts_with("import ") && !trimmed.starts_with("export ") {
694            continue;
695        }
696        for quote in ['"', '\''] {
697            let Some(start) = trimmed.find(quote) else {
698                continue;
699            };
700            let rest = &trimmed[start + quote.len_utf8()..];
701            let Some(end) = rest.find(quote) else {
702                continue;
703            };
704            let specifier = rest[..end].to_owned();
705            if !specifiers.contains(&specifier) {
706                specifiers.push(specifier);
707            }
708        }
709    }
710    specifiers
711}
712
713fn count_file_lines(contents: &str) -> u64 {
714    if contents.is_empty() {
715        0
716    } else {
717        contents.bytes().filter(|byte| *byte == b'\n').count() as u64
718    }
719}
720
721fn workspace_package_name(root: &Path, file_path: &Path) -> Option<String> {
722    let segments = project_segments(root, file_path);
723    if segments
724        .first()
725        .is_some_and(|segment| segment == "packages")
726    {
727        segments.get(1).cloned()
728    } else {
729        None
730    }
731}
732
733fn project_segments(root: &Path, path: &Path) -> Vec<String> {
734    project_path(root, path)
735        .split('/')
736        .filter(|segment| !segment.is_empty())
737        .map(ToOwned::to_owned)
738        .collect()
739}
740
741fn object(entries: impl IntoIterator<Item = (&'static str, JsonValue)>) -> JsonObject {
742    BTreeMap::from_iter(
743        entries
744            .into_iter()
745            .map(|(key, value)| (key.to_owned(), value)),
746    )
747}
748
749fn string_value(value: &str) -> JsonValue {
750    JsonValue::String(value.to_owned())
751}
752
753fn number_value(value: u64) -> JsonValue {
754    JsonValue::Number(JsonNumber::U64(value))
755}
756
757#[cfg(test)]
758mod tests {
759    use super::validate_skill_profile;
760
761    const VALID_PROFILE: &str = r#"
762runners:
763  main:
764    default: true
765    type: agent-task
766    agent: builder
767    task: probe
768    outputs:
769      result: string
770    inputs:
771      objective:
772        type: string
773        required: true
774        description: "x"
775harness:
776  cases:
777    - name: ok
778      inputs:
779        objective: x
780      caller:
781        answers:
782          agent_task.probe.output:
783            result: ok
784      expect:
785        status: sealed
786        receipt:
787          schema: runx.receipt.v1
788          state: sealed
789          disposition: closed
790"#;
791
792    const INVALID_HARNESS_STATUS_PROFILE: &str = r#"
793runners:
794  main:
795    default: true
796    type: agent-task
797    agent: builder
798    task: probe
799    outputs:
800      result: string
801    inputs:
802      objective:
803        type: string
804        required: true
805        description: "x"
806harness:
807  cases:
808    - name: bad
809      inputs:
810        objective: x
811      caller:
812        answers:
813          agent_task.probe.output:
814            result: ok
815      expect:
816        status: success
817        receipt:
818          schema: runx.receipt.v1
819          state: sealed
820          disposition: closed
821"#;
822
823    #[test]
824    fn valid_execution_profile_passes() {
825        assert!(validate_skill_profile(VALID_PROFILE).is_ok());
826    }
827
828    #[test]
829    fn invalid_harness_status_is_rejected() {
830        let result = validate_skill_profile(INVALID_HARNESS_STATUS_PROFILE);
831        assert!(
832            result.is_err(),
833            "an invalid harness expect.status must be rejected by doctor"
834        );
835        if let Err(message) = result {
836            assert!(
837                message.contains("must be sealed"),
838                "unexpected error message: {message}"
839            );
840        }
841    }
842}