Skip to main content

heddle_cli_contract/cli/commands/
doctor_schemas.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `heddle doctor schemas` — drift-check `docs/json-schemas.md`
3//! against schema verbs registered by the command contract table.
4//!
5//! Strategy
6//! --------
7//! 1. For every schema verb documented by the command contract table,
8//!    generate the canonical schema.
9//! 2. Parse `docs/json-schemas.md` and extract the literal JSON
10//!    sample(s) under each `## heddle <verb> --output json` section.
11//! 3. For each extracted sample, compare its top-level keys against
12//!    the schema's `properties` keys. Report any sample key that
13//!    isn't a property in the schema (the most common drift —
14//!    field renames, deletions, typos).
15//!
16//! We deliberately do not pull in a full JSON-Schema validator here:
17//! disk is tight, and the keys-only check catches every drift class
18//! the doc has historically suffered (renames, deletions, leaks of
19//! fields like `git_import_guidance` into per-command outputs).
20//!
21//! Pure sample/schema helpers live in `heddle_core::doctor_schemas_plan`.
22
23use 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/// One drift finding.
46#[derive(Debug, Clone, Serialize)]
47pub struct SchemaIssue {
48    /// Verb the sample documents.
49    pub verb: String,
50    /// 1-based line in `docs/json-schemas.md` where the sample begins.
51    pub line: usize,
52    /// Sample key the schema doesn't declare.
53    pub unknown_key: String,
54    /// One-line human description for the text renderer.
55    pub detail: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct SchemaReport {
60    /// Stable output discriminator for JSON callers.
61    pub output_kind: &'static str,
62    /// One-glance machine status for this doctor surface.
63    pub status: String,
64    /// True only when docs, generated schemas, and catalog coverage agree.
65    #[serde(rename = "verified")]
66    pub verified: bool,
67    /// Human-readable summary of the schema/doc contract.
68    pub summary: String,
69    /// Primary command to rerun or inspect when the report is not verified.
70    pub recommended_action: Option<String>,
71    /// Canonical fillable template for `recommended_action`; `null` when
72    /// verified (no action). The always-null `_argv` sidecar was dropped
73    /// (HeddleCo/heddle#254).
74    pub recommended_action_template: Option<ActionTemplate>,
75    /// Recovery/inspection commands in priority order.
76    pub recovery_commands: Vec<String>,
77    /// All verbs the runtime schema registry exposes.
78    pub registered_verbs: Vec<String>,
79    /// Runtime schema verbs selected by the command contract table
80    /// for sample validation in `docs/json-schemas.md`.
81    pub documented_verbs: Vec<String>,
82    /// Runtime schema verbs that have generated schemas but are not
83    /// yet selected for docs sample validation. These are coverage
84    /// gaps, not drift failures.
85    pub undocumented_verbs: Vec<String>,
86    /// Documented verbs the doc doesn't have a `## heddle <verb>
87    /// --output json` section for. (Some sections intentionally bind several
88    /// verbs to one sample via inline hints; those are still matched.)
89    pub unmatched_verbs: Vec<String>,
90    /// Verbs the doc has a sample for, with all keys validating.
91    pub passing_verbs: Vec<String>,
92    /// Drift findings.
93    pub issues: Vec<SchemaIssue>,
94    /// Catalog-wide schema coverage for every JSON-capable command.
95    /// This is broader than `registered_verbs`: registered/documented
96    /// verbs prove schema drift, while this field shows the remaining
97    /// command catalog gap without making drift checks ambiguous.
98    pub command_contract_schema_coverage: CommandContractSchemaCoverage,
99    /// Path to the doc that was checked.
100    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
236/// Public entrypoint for `heddle doctor schemas`.
237pub 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                // Should never happen because the unit test pins it,
316                // but stay defensive.
317                continue;
318            }
319        };
320        let property_keys = schema_property_keys(&schema);
321        let allows_additional_properties = schema_allows_additional_properties(&schema);
322
323        // Look up the sample(s) for this verb. Missing samples are
324        // reported as "unmatched" rather than as drift, so dropping
325        // a verb from the doc shows up here without rejecting CI.
326        //
327        // Two paths into a sample:
328        //   1. The inline verb hint (e.g. text like `heddle marker
329        //      create|delete|show` immediately above the fence) —
330        //      this is the precise binding when a section has
331        //      multiple samples for distinct verbs.
332        //   2. Fallback to the section heading.
333        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                    // Sample is the literal `null` (e.g. `review
360                    // next` empty case) or a non-object value;
361                    // nothing to compare key-wise.
362                    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
652/// Every `docs/json-schemas.md` ```json sample that binds to at least one
653/// verb in `verbs`, paired with the subset of `verbs` it binds to (via
654/// section heading or inline `heddle <verb>` hint), in document order.
655///
656/// Thin re-export of the pure core binder so integration tests keep the
657/// same path through the CLI command module.
658pub 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
662/// Walk parents of `start` until we find a directory containing a
663/// `.heddle/` or `.git/` folder. Same heuristic as
664/// [`super::doctor_docs::find_repo_root`] but kept private to this
665/// module to avoid a doc-only intermodule coupling.
666fn 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}