1use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result, anyhow};
26use heddle_core::doctor_schemas_plan::{
27 DocSample, SchemaCoverageBlockingFacts, collect_coverage_field_drifts,
28 coverage_has_no_blocking_schema_gaps as coverage_gaps_clean, doctor_schemas_json_sample_span,
29 documented_samples_with_bound_verbs as core_documented_samples_with_bound_verbs,
30 extract_samples, sample_matches_verb_with_hints, schema_allows_additional_properties,
31 schema_property_keys, top_level_keys,
32};
33use serde::Serialize;
34use serde_json::Value;
35use sley::Repository as SleyRepository;
36
37use super::{
38 advice::RecoveryAdvice,
39 command_catalog::{ActionTemplate, recommended_action_template},
40 schemas::{documented_schema_verbs, schema_for_verb, schema_verbs},
41 verification_health::{MachineContractCoverage, machine_contract_coverage},
42};
43use crate::cli::{Cli, DoctorSchemasArgs, should_output_json};
44
45#[derive(Debug, Clone, Serialize)]
47pub struct SchemaIssue {
48 pub verb: String,
50 pub line: usize,
52 pub unknown_key: String,
54 pub detail: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct SchemaReport {
60 pub output_kind: &'static str,
62 pub status: String,
64 #[serde(rename = "verified")]
66 pub verified: bool,
67 pub summary: String,
69 pub recommended_action: Option<String>,
71 pub recommended_action_template: Option<ActionTemplate>,
75 pub recovery_commands: Vec<String>,
77 pub registered_verbs: Vec<String>,
79 pub documented_verbs: Vec<String>,
82 pub undocumented_verbs: Vec<String>,
86 pub unmatched_verbs: Vec<String>,
90 pub passing_verbs: Vec<String>,
92 pub issues: Vec<SchemaIssue>,
94 pub command_contract_schema_coverage: CommandContractSchemaCoverage,
99 pub doc_path: String,
101}
102
103#[derive(Debug, Clone, Serialize)]
104pub struct CommandContractSchemaCoverage {
105 pub status: String,
106 #[serde(rename = "verified_scope")]
107 pub verified_scope: String,
108 pub advanced_scope: String,
109 pub summary: String,
110 pub catalog_commands_total: usize,
111 pub catalog_mutating_commands_total: usize,
112 pub json_commands_total: usize,
113 pub json_mutating_commands_total: usize,
114 pub json_commands_with_schema: usize,
115 pub json_commands_with_accepted_opaque_schema: usize,
116 pub json_commands_without_schema: usize,
117 #[serde(rename = "verified_scope_json_commands_total")]
118 pub verified_scope_json_commands_total: usize,
119 #[serde(rename = "verified_scope_json_commands_with_schema")]
120 pub verified_scope_json_commands_with_schema: usize,
121 #[serde(rename = "verified_scope_json_commands_with_accepted_opaque_schema")]
122 pub verified_scope_json_commands_with_accepted_opaque_schema: usize,
123 #[serde(rename = "verified_scope_json_commands_without_schema")]
124 pub verified_scope_json_commands_without_schema: usize,
125 pub advanced_scope_json_commands_total: usize,
126 pub advanced_scope_json_commands_with_accepted_opaque_schema: usize,
127 pub mutating_commands_total: usize,
128 pub mutating_commands_with_schema: usize,
129 pub mutating_commands_with_accepted_opaque_schema: usize,
130 pub mutating_commands_without_schema: usize,
131 #[serde(rename = "verified_scope_mutating_commands_total")]
132 pub verified_scope_mutating_commands_total: usize,
133 #[serde(rename = "verified_scope_mutating_commands_with_schema")]
134 pub verified_scope_mutating_commands_with_schema: usize,
135 #[serde(rename = "verified_scope_mutating_commands_with_accepted_opaque_schema")]
136 pub verified_scope_mutating_commands_with_accepted_opaque_schema: usize,
137 #[serde(rename = "verified_scope_mutating_commands_without_schema")]
138 pub verified_scope_mutating_commands_without_schema: usize,
139 pub advanced_scope_mutating_commands_total: usize,
140 pub advanced_scope_mutating_commands_with_accepted_opaque_schema: usize,
141 pub undocumented_schema_verbs_total: usize,
142 pub opaque_schema_verbs_total: usize,
143 pub accepted_opaque_schema_verbs_total: usize,
144 pub unaccepted_opaque_schema_verbs_total: usize,
145 pub missing_schema_examples: Vec<String>,
146 pub missing_mutating_schema_examples: Vec<String>,
147 #[serde(rename = "verified_scope_missing_schema_examples")]
148 pub verified_scope_missing_schema_examples: Vec<String>,
149 #[serde(rename = "verified_scope_accepted_opaque_schema_examples")]
150 pub verified_scope_accepted_opaque_schema_examples: Vec<String>,
151 pub advanced_scope_accepted_opaque_schema_examples: Vec<String>,
152 pub accepted_opaque_schema_examples: Vec<String>,
153 pub unaccepted_opaque_schema_examples: Vec<String>,
154 pub undocumented_schema_examples: Vec<String>,
155}
156
157impl From<MachineContractCoverage> for CommandContractSchemaCoverage {
158 fn from(coverage: MachineContractCoverage) -> Self {
159 Self {
160 status: coverage.status,
161 verified_scope: coverage.verified_scope,
162 advanced_scope: coverage.advanced_scope,
163 summary: coverage.summary,
164 catalog_commands_total: coverage.catalog_commands_total,
165 catalog_mutating_commands_total: coverage.catalog_mutating_commands_total,
166 json_commands_total: coverage.json_commands_total,
167 json_mutating_commands_total: coverage.json_mutating_commands_total,
168 json_commands_with_schema: coverage.json_commands_with_schema,
169 json_commands_with_accepted_opaque_schema: coverage
170 .json_commands_with_accepted_opaque_schema,
171 json_commands_without_schema: coverage.json_commands_without_schema,
172 verified_scope_json_commands_total: coverage.verified_scope_json_commands_total,
173 verified_scope_json_commands_with_schema: coverage
174 .verified_scope_json_commands_with_schema,
175 verified_scope_json_commands_with_accepted_opaque_schema: coverage
176 .verified_scope_json_commands_with_accepted_opaque_schema,
177 verified_scope_json_commands_without_schema: coverage
178 .verified_scope_json_commands_without_schema,
179 advanced_scope_json_commands_total: coverage.advanced_scope_json_commands_total,
180 advanced_scope_json_commands_with_accepted_opaque_schema: coverage
181 .advanced_scope_json_commands_with_accepted_opaque_schema,
182 mutating_commands_total: coverage.mutating_commands_total,
183 mutating_commands_with_schema: coverage.mutating_commands_with_schema,
184 mutating_commands_with_accepted_opaque_schema: coverage
185 .mutating_commands_with_accepted_opaque_schema,
186 mutating_commands_without_schema: coverage.mutating_commands_without_schema,
187 verified_scope_mutating_commands_total: coverage.verified_scope_mutating_commands_total,
188 verified_scope_mutating_commands_with_schema: coverage
189 .verified_scope_mutating_commands_with_schema,
190 verified_scope_mutating_commands_with_accepted_opaque_schema: coverage
191 .verified_scope_mutating_commands_with_accepted_opaque_schema,
192 verified_scope_mutating_commands_without_schema: coverage
193 .verified_scope_mutating_commands_without_schema,
194 advanced_scope_mutating_commands_total: coverage.advanced_scope_mutating_commands_total,
195 advanced_scope_mutating_commands_with_accepted_opaque_schema: coverage
196 .advanced_scope_mutating_commands_with_accepted_opaque_schema,
197 undocumented_schema_verbs_total: coverage.undocumented_schema_verbs_total,
198 opaque_schema_verbs_total: coverage.opaque_schema_verbs_total,
199 accepted_opaque_schema_verbs_total: coverage.accepted_opaque_schema_verbs_total,
200 unaccepted_opaque_schema_verbs_total: coverage.unaccepted_opaque_schema_verbs_total,
201 missing_schema_examples: coverage.missing_schema_examples,
202 missing_mutating_schema_examples: coverage.missing_mutating_schema_examples,
203 verified_scope_missing_schema_examples: coverage.verified_scope_missing_schema_examples,
204 verified_scope_accepted_opaque_schema_examples: coverage
205 .verified_scope_accepted_opaque_schema_examples,
206 advanced_scope_accepted_opaque_schema_examples: coverage
207 .advanced_scope_accepted_opaque_schema_examples,
208 accepted_opaque_schema_examples: coverage.accepted_opaque_schema_examples,
209 unaccepted_opaque_schema_examples: coverage.unaccepted_opaque_schema_examples,
210 undocumented_schema_examples: coverage.undocumented_schema_examples,
211 }
212 }
213}
214
215impl CommandContractSchemaCoverage {
216 fn blocking_facts(&self) -> SchemaCoverageBlockingFacts {
217 SchemaCoverageBlockingFacts {
218 verified_scope_json_commands_without_schema: self
219 .verified_scope_json_commands_without_schema,
220 verified_scope_mutating_commands_without_schema: self
221 .verified_scope_mutating_commands_without_schema,
222 verified_scope_json_commands_with_accepted_opaque_schema: self
223 .verified_scope_json_commands_with_accepted_opaque_schema,
224 verified_scope_mutating_commands_with_accepted_opaque_schema: self
225 .verified_scope_mutating_commands_with_accepted_opaque_schema,
226 unaccepted_opaque_schema_verbs_total: self.unaccepted_opaque_schema_verbs_total,
227 undocumented_schema_verbs_total: self.undocumented_schema_verbs_total,
228 }
229 }
230}
231
232fn coverage_has_no_blocking_schema_gaps(coverage: &CommandContractSchemaCoverage) -> bool {
233 coverage_gaps_clean(coverage.blocking_facts())
234}
235
236pub fn cmd_doctor_schemas(cli: &Cli, args: DoctorSchemasArgs) -> Result<()> {
238 let json = should_output_json(cli, None);
239 let repo_root = cli.repo.clone().map(Ok).unwrap_or_else(|| {
240 std::env::current_dir().map(|cwd| find_repo_root(&cwd).unwrap_or(cwd))
241 })?;
242
243 let doc_path = repo_root.join("docs").join("json-schemas.md");
244 if !doc_path.exists() {
245 return Err(anyhow!(doctor_schemas_doc_missing_advice(&repo_root)));
246 }
247 let mut doc = std::fs::read_to_string(&doc_path)
248 .with_context(|| format!("read {}", doc_path.display()))?;
249
250 if args.update_docs {
251 let coverage = current_command_contract_schema_coverage();
252 let updated = refresh_command_contract_coverage_sample(&doc, &coverage)
253 .with_context(|| format!("update {}", doc_path.display()))?;
254 if updated != doc {
255 std::fs::write(&doc_path, updated.as_bytes())
256 .with_context(|| format!("write {}", doc_path.display()))?;
257 doc = updated;
258 if !json {
259 println!(
260 "Updated command-contract schema coverage sample in {}",
261 doc_path.display()
262 );
263 println!();
264 }
265 } else if !json {
266 println!(
267 "Command-contract schema coverage sample is already current in {}",
268 doc_path.display()
269 );
270 println!();
271 }
272 }
273
274 let report = build_schema_report(&doc_path, &doc)?;
275 validate_report(&report)?;
276
277 if json {
278 println!("{}", serde_json::to_string_pretty(&report)?);
279 } else {
280 render_human(&report);
281 }
282
283 Ok(())
284}
285
286fn build_schema_report(doc_path: &Path, doc: &str) -> Result<SchemaReport> {
287 let samples = extract_samples(doc);
288
289 let mut issues = Vec::new();
290 let mut passing_verbs = Vec::new();
291 let mut unmatched_verbs = Vec::new();
292
293 let registered_verbs: Vec<String> = schema_verbs().iter().map(|s| s.to_string()).collect();
294 let documented_verbs: Vec<String> = documented_schema_verbs()
295 .iter()
296 .map(|s| s.to_string())
297 .collect();
298 let undocumented_verbs: Vec<String> = schema_verbs()
299 .iter()
300 .filter(|verb| !documented_schema_verbs().contains(verb))
301 .map(|s| s.to_string())
302 .collect();
303
304 let machine_coverage = machine_contract_coverage();
305 let machine_coverage_value = serde_json::to_value(&machine_coverage)
306 .unwrap_or_else(|_| Value::Object(Default::default()));
307 let coverage: CommandContractSchemaCoverage = machine_coverage.into();
308 let command_coverage_value =
309 serde_json::to_value(&coverage).unwrap_or_else(|_| Value::Object(Default::default()));
310
311 for verb in documented_schema_verbs() {
312 let schema = match schema_for_verb(verb) {
313 Some(s) => s,
314 None => {
315 continue;
318 }
319 };
320 let property_keys = schema_property_keys(&schema);
321 let allows_additional_properties = schema_allows_additional_properties(&schema);
322
323 let verb_samples: Vec<&DocSample> = samples
334 .iter()
335 .filter(|s| sample_matches_verb_with_hints(s, verb))
336 .collect();
337
338 if verb_samples.is_empty() {
339 unmatched_verbs.push((*verb).to_string());
340 continue;
341 }
342
343 let mut verb_clean = true;
344 for sample in verb_samples {
345 let coverage_issue_count = issues.len();
346 collect_coverage_drift_issues(
347 sample,
348 verb,
349 &machine_coverage_value,
350 &command_coverage_value,
351 &mut issues,
352 );
353 if issues.len() != coverage_issue_count {
354 verb_clean = false;
355 }
356 let sample_keys = match top_level_keys(&sample.json) {
357 Some(keys) => keys,
358 None => {
359 continue;
363 }
364 };
365 if allows_additional_properties {
366 continue;
367 }
368 for key in sample_keys {
369 if !property_keys.contains(&key) {
370 verb_clean = false;
371 issues.push(SchemaIssue {
372 verb: (*verb).to_string(),
373 line: sample.start_line,
374 unknown_key: key.clone(),
375 detail: format!("sample has field '{key}', but schema does not declare it"),
376 });
377 }
378 }
379 }
380 if verb_clean {
381 passing_verbs.push((*verb).to_string());
382 }
383 }
384
385 let verified = coverage_has_no_blocking_schema_gaps(&coverage)
386 && unmatched_verbs.is_empty()
387 && issues.is_empty();
388 let status = if verified {
389 coverage.status.clone()
390 } else if !issues.is_empty() {
391 "drift".to_string()
392 } else if !unmatched_verbs.is_empty() {
393 "missing_samples".to_string()
394 } else {
395 coverage.status.clone()
396 };
397 let summary = if verified {
398 coverage.summary.clone()
399 } else if !issues.is_empty() {
400 format!("{} schema drift issue(s) found", issues.len())
401 } else if !unmatched_verbs.is_empty() {
402 format!(
403 "{} documented schema verb(s) lack parseable samples",
404 unmatched_verbs.len()
405 )
406 } else {
407 coverage.summary.clone()
408 };
409 let recommended_action = (!verified).then(|| "heddle doctor schemas --output json".to_string());
410 let recommended_action_template = recommended_action
411 .as_deref()
412 .and_then(recommended_action_template);
413 let recovery_commands = if verified {
414 Vec::new()
415 } else {
416 vec!["heddle doctor schemas --output json".to_string()]
417 };
418
419 Ok(SchemaReport {
420 output_kind: "doctor_schemas",
421 status,
422 verified,
423 summary,
424 recommended_action,
425 recommended_action_template,
426 recovery_commands,
427 registered_verbs,
428 documented_verbs,
429 undocumented_verbs,
430 unmatched_verbs,
431 passing_verbs,
432 issues,
433 command_contract_schema_coverage: coverage,
434 doc_path: doc_path.display().to_string(),
435 })
436}
437
438fn current_command_contract_schema_coverage() -> CommandContractSchemaCoverage {
439 machine_contract_coverage().into()
440}
441
442fn refresh_command_contract_coverage_sample(
443 doc: &str,
444 coverage: &CommandContractSchemaCoverage,
445) -> Result<String> {
446 let (start, end) =
447 doctor_schemas_json_sample_span(doc).map_err(|err| anyhow!(err.message()))?;
448 let sample_text = &doc[start..end];
449 let mut sample: Value =
450 serde_json::from_str(sample_text).context("parse doctor schemas JSON sample")?;
451 let Some(object) = sample.as_object_mut() else {
452 return Err(anyhow!(
453 "`heddle doctor schemas --output json` sample must be a JSON object"
454 ));
455 };
456
457 object.insert(
458 "summary".to_string(),
459 Value::String(coverage.summary.clone()),
460 );
461 object.insert(
462 "command_contract_schema_coverage".to_string(),
463 serde_json::to_value(coverage).context("serialize command-contract schema coverage")?,
464 );
465
466 let rendered =
467 serde_json::to_string_pretty(&sample).context("render doctor schemas JSON sample")?;
468 let mut updated = String::with_capacity(doc.len() + rendered.len());
469 updated.push_str(&doc[..start]);
470 updated.push_str(&rendered);
471 updated.push_str(&doc[end..]);
472 Ok(updated)
473}
474
475fn validate_report(report: &SchemaReport) -> Result<()> {
476 if !coverage_has_no_blocking_schema_gaps(&report.command_contract_schema_coverage) {
477 return Err(anyhow!(schema_contract_advice(report)));
478 }
479 if !report.unmatched_verbs.is_empty() {
480 return Err(anyhow!(schema_contract_advice(report)));
481 }
482 if !report.issues.is_empty() {
483 return Err(anyhow!(schema_contract_advice(report)));
484 }
485 Ok(())
486}
487
488fn collect_coverage_drift_issues(
489 sample: &DocSample,
490 verb: &str,
491 machine_coverage: &Value,
492 command_coverage: &Value,
493 issues: &mut Vec<SchemaIssue>,
494) {
495 for drift in collect_coverage_field_drifts(&sample.json, machine_coverage, command_coverage) {
496 issues.push(SchemaIssue {
497 verb: verb.to_string(),
498 line: sample.start_line,
499 unknown_key: drift.field_path,
500 detail: drift.detail,
501 });
502 }
503}
504
505fn doctor_schemas_doc_missing_advice(repo_root: &Path) -> RecoveryAdvice {
506 RecoveryAdvice::safety_refusal(
507 "doctor_schemas_source_docs_missing",
508 "Cannot run schema docs drift check outside a Heddle source checkout",
509 "Run this from the Heddle source checkout, pass `--repo <source-root>`, or use `heddle help --output json` and `heddle schemas status` for installed CLI introspection.",
510 format!(
511 "docs/json-schemas.md was not found under {}",
512 repo_root.display()
513 ),
514 "`doctor schemas` compares runtime schemas to source documentation and cannot prove docs drift without that markdown file",
515 "no repository objects, refs, metadata, or worktree files were changed",
516 "heddle help --output json",
517 vec![
518 "heddle help --output json".to_string(),
519 "heddle schemas status".to_string(),
520 "heddle doctor schemas --output json".to_string(),
521 ],
522 )
523}
524
525fn schema_contract_advice(report: &SchemaReport) -> RecoveryAdvice {
526 let coverage = &report.command_contract_schema_coverage;
527 let error = if !coverage_has_no_blocking_schema_gaps(coverage) {
528 format!(
529 "Machine contract coverage is incomplete: {}",
530 coverage.summary
531 )
532 } else if !report.unmatched_verbs.is_empty() {
533 format!(
534 "{} documented schema verb(s) lack parseable samples",
535 report.unmatched_verbs.len()
536 )
537 } else if !report.issues.is_empty() {
538 format!("{} schema drift issue(s) found", report.issues.len())
539 } else {
540 "Machine contract check failed".to_string()
541 };
542 let unsafe_condition = if !coverage_has_no_blocking_schema_gaps(coverage) {
543 format!(
544 "command catalog status `{}`; verified scope `{}` has {} JSON-capable command(s), {} mutating command(s), {} opaque schema-backed JSON command(s), {} unaccepted opaque schema verb(s), and {} runtime schema verb(s) outside the documented schema contract",
545 coverage.status,
546 coverage.verified_scope,
547 coverage.verified_scope_json_commands_without_schema,
548 coverage.verified_scope_mutating_commands_without_schema,
549 coverage.verified_scope_json_commands_with_accepted_opaque_schema,
550 coverage.unaccepted_opaque_schema_verbs_total,
551 coverage.undocumented_schema_verbs_total
552 )
553 } else if !report.unmatched_verbs.is_empty() {
554 format!(
555 "documented schema verbs without parseable samples: {}",
556 report.unmatched_verbs.join(", ")
557 )
558 } else {
559 let examples = report
560 .issues
561 .iter()
562 .take(3)
563 .map(|issue| {
564 format!(
565 "{} line {} unknown `{}`",
566 issue.verb, issue.line, issue.unknown_key
567 )
568 })
569 .collect::<Vec<_>>()
570 .join("; ");
571 format!("documented JSON samples drifted from generated schemas: {examples}")
572 };
573 RecoveryAdvice::machine_contract_drift(error, unsafe_condition)
574}
575
576fn render_human(report: &SchemaReport) {
577 println!(
578 "heddle doctor schemas — {} runtime schema verb(s), {} documented, doc: {}",
579 report.registered_verbs.len(),
580 report.documented_verbs.len(),
581 report.doc_path
582 );
583 println!();
584 println!(
585 " catalog_schema_coverage {}: {}",
586 report.command_contract_schema_coverage.status,
587 report.command_contract_schema_coverage.summary
588 );
589 for verb in &report.passing_verbs {
590 println!(" ok {verb}: sample matches generated schema");
591 }
592 if !report.unmatched_verbs.is_empty() {
593 println!();
594 println!(
595 " -- {} documented verb(s) without a parseable sample:",
596 report.unmatched_verbs.len()
597 );
598 for verb in &report.unmatched_verbs {
599 println!(" {verb}");
600 }
601 }
602 if !report.undocumented_verbs.is_empty() {
603 println!();
604 println!(
605 " coverage_gap {} runtime schema verb(s) not yet sample-checked by docs:",
606 report.undocumented_verbs.len()
607 );
608 for verb in &report.undocumented_verbs {
609 println!(" {verb}");
610 }
611 }
612 if !report.issues.is_empty() {
613 println!();
614 for issue in &report.issues {
615 println!(
616 " drift {}: {} (doc line {})",
617 issue.verb, issue.detail, issue.line
618 );
619 }
620 println!();
621 println!("Found {} drift issue(s).", report.issues.len());
622 } else {
623 println!();
624 println!("No registered-schema/documented-sample drift detected.");
625 if report
626 .command_contract_schema_coverage
627 .json_commands_without_schema
628 > 0
629 {
630 println!(
631 "Catalog schema coverage is partial; {} JSON-capable command(s) still need registered schemas.",
632 report
633 .command_contract_schema_coverage
634 .json_commands_without_schema
635 );
636 }
637 if report
638 .command_contract_schema_coverage
639 .accepted_opaque_schema_verbs_total
640 > 0
641 {
642 println!(
643 "Verified everyday/agent scope is fully concrete; advanced/internal/admin scope carries {} intentionally object-shaped opaque schema verb(s).",
644 report
645 .command_contract_schema_coverage
646 .accepted_opaque_schema_verbs_total
647 );
648 }
649 }
650}
651
652pub fn documented_samples_with_bound_verbs(doc: &str, verbs: &[&str]) -> Vec<(Value, Vec<String>)> {
659 core_documented_samples_with_bound_verbs(doc, verbs)
660}
661
662fn find_repo_root(start: &Path) -> Option<PathBuf> {
667 for ancestor in start.ancestors() {
668 if ancestor.join(".heddle").exists() || ancestor.join(".git").exists() {
669 return Some(ancestor.to_path_buf());
670 }
671 }
672 let repo = SleyRepository::discover(start).ok()?;
673 repo.workdir()
674 .or_else(|| repo.git_dir().parent().map(Path::to_path_buf))
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn coverage_samples_must_match_runtime_coverage_values() {
683 let sample = DocSample {
684 heading: "`heddle status --output json`".to_string(),
685 inline_verb: None,
686 start_line: 42,
687 json: serde_json::json!({
688 "verification": {
689 "machine_contract_coverage": {
690 "summary": "old",
691 "json_commands_total": 1,
692 "accepted_opaque_schema_examples": ["help"]
693 }
694 },
695 "command_contract_schema_coverage": {
696 "summary": "old doctor",
697 "json_commands_with_schema": 10
698 }
699 }),
700 };
701 let machine = serde_json::json!({
702 "summary": "new",
703 "json_commands_total": 2,
704 "accepted_opaque_schema_examples": ["help", "redact list"]
705 });
706 let command = serde_json::json!({
707 "summary": "new doctor",
708 "json_commands_with_schema": 11
709 });
710 let mut issues = Vec::new();
711
712 collect_coverage_drift_issues(&sample, "status", &machine, &command, &mut issues);
713
714 let fields = issues
715 .iter()
716 .map(|issue| issue.unknown_key.as_str())
717 .collect::<Vec<_>>();
718 assert!(fields.contains(&"verification.machine_contract_coverage.summary"));
719 assert!(fields.contains(&"verification.machine_contract_coverage.json_commands_total"));
720 assert!(
721 fields.contains(
722 &"verification.machine_contract_coverage.accepted_opaque_schema_examples"
723 )
724 );
725 assert!(fields.contains(&"command_contract_schema_coverage.summary"));
726 assert!(fields.contains(&"command_contract_schema_coverage.json_commands_with_schema"));
727 assert!(issues.iter().all(|issue| issue.line == 42));
728 }
729
730 #[test]
731 fn update_docs_refreshes_command_contract_coverage_sample() {
732 let coverage = current_command_contract_schema_coverage();
733 let doc = r#"
734## `heddle doctor schemas --output json`
735
736```json
737{
738 "output_kind": "doctor_schemas",
739 "status": "available",
740 "verified": true,
741 "summary": "stale",
742 "recommended_action": null,
743 "recovery_commands": [],
744 "registered_verbs": ["status"],
745 "documented_verbs": ["status"],
746 "undocumented_verbs": [],
747 "unmatched_verbs": [],
748 "passing_verbs": ["status"],
749 "issues": [],
750 "command_contract_schema_coverage": {
751 "summary": "stale",
752 "catalog_commands_total": 0
753 },
754 "doc_path": "/repo/docs/json-schemas.md"
755}
756```
757"#;
758
759 let updated =
760 refresh_command_contract_coverage_sample(doc, &coverage).expect("refresh sample");
761 let samples = extract_samples(&updated);
762 let sample = samples
763 .iter()
764 .find(|sample| sample.heading == "`heddle doctor schemas --output json`")
765 .expect("doctor schemas sample should still parse");
766
767 assert_eq!(sample.json["summary"], serde_json::json!(coverage.summary));
768 assert_eq!(
769 sample.json["command_contract_schema_coverage"],
770 serde_json::to_value(&coverage).unwrap()
771 );
772 }
773
774 #[test]
775 fn validate_report_returns_typed_recovery_advice_for_schema_failure() {
776 let report = SchemaReport {
777 output_kind: "doctor_schemas",
778 status: "missing_samples".to_string(),
779 verified: false,
780 summary: "1 documented schema verb lacks parseable samples".to_string(),
781 recommended_action: Some("heddle doctor schemas --output json".to_string()),
782 recommended_action_template: recommended_action_template(
783 "heddle doctor schemas --output json",
784 ),
785 recovery_commands: vec!["heddle doctor schemas --output json".to_string()],
786 registered_verbs: vec!["status".to_string()],
787 documented_verbs: vec!["status".to_string()],
788 undocumented_verbs: Vec::new(),
789 unmatched_verbs: vec!["status".to_string()],
790 passing_verbs: Vec::new(),
791 issues: Vec::new(),
792 command_contract_schema_coverage: CommandContractSchemaCoverage {
793 status: "available".to_string(),
794 verified_scope: "everyday_and_agent".to_string(),
795 advanced_scope: "advanced_internal_admin".to_string(),
796 summary: "all JSON-capable commands have schemas".to_string(),
797 catalog_commands_total: 1,
798 catalog_mutating_commands_total: 0,
799 json_commands_total: 1,
800 json_mutating_commands_total: 0,
801 json_commands_with_schema: 1,
802 json_commands_with_accepted_opaque_schema: 0,
803 json_commands_without_schema: 0,
804 verified_scope_json_commands_total: 1,
805 verified_scope_json_commands_with_schema: 1,
806 verified_scope_json_commands_with_accepted_opaque_schema: 0,
807 verified_scope_json_commands_without_schema: 0,
808 advanced_scope_json_commands_total: 0,
809 advanced_scope_json_commands_with_accepted_opaque_schema: 0,
810 mutating_commands_total: 0,
811 mutating_commands_with_schema: 0,
812 mutating_commands_with_accepted_opaque_schema: 0,
813 mutating_commands_without_schema: 0,
814 verified_scope_mutating_commands_total: 0,
815 verified_scope_mutating_commands_with_schema: 0,
816 verified_scope_mutating_commands_with_accepted_opaque_schema: 0,
817 verified_scope_mutating_commands_without_schema: 0,
818 advanced_scope_mutating_commands_total: 0,
819 advanced_scope_mutating_commands_with_accepted_opaque_schema: 0,
820 undocumented_schema_verbs_total: 0,
821 opaque_schema_verbs_total: 0,
822 accepted_opaque_schema_verbs_total: 0,
823 unaccepted_opaque_schema_verbs_total: 0,
824 missing_schema_examples: Vec::new(),
825 missing_mutating_schema_examples: Vec::new(),
826 verified_scope_missing_schema_examples: Vec::new(),
827 verified_scope_accepted_opaque_schema_examples: Vec::new(),
828 advanced_scope_accepted_opaque_schema_examples: Vec::new(),
829 accepted_opaque_schema_examples: Vec::new(),
830 unaccepted_opaque_schema_examples: Vec::new(),
831 undocumented_schema_examples: Vec::new(),
832 },
833 doc_path: "docs/json-schemas.md".to_string(),
834 };
835
836 let err = validate_report(&report).expect_err("schema failure should be rejected");
837 let advice = err
838 .chain()
839 .find_map(|cause| cause.downcast_ref::<RecoveryAdvice>())
840 .expect("schema failure should use typed recovery advice");
841 assert_eq!(advice.kind, "machine_contract_drift");
842 assert_eq!(
843 advice.primary_command,
844 "heddle doctor schemas --output json"
845 );
846 assert_eq!(
847 advice.recovery_commands,
848 vec!["heddle doctor schemas --output json".to_string()]
849 );
850 assert!(!advice.hint.trim().is_empty());
851 assert!(!advice.unsafe_condition.trim().is_empty());
852 assert!(!advice.would_change.trim().is_empty());
853 assert!(!advice.preserved.trim().is_empty());
854 }
855}