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
18const 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
126fn 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
201fn 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
419fn 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
531fn 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
561fn 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}