Skip to main content

heddle_cli_contract/cli/commands/
doctor_docs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `heddle doctor docs` — diff-check markdown docs against the live CLI surface.
3//!
4//! Walks every `heddle <verb> [<subverb>] [flags]` invocation embedded in a
5//! markdown file and reports any drift: verbs that no longer exist, long
6//! flags that aren't on that verb, or literal values for finite-valued
7//! flags that aren't in the valid set. Current guidance also receives a
8//! semantic policy pass for retired and authority-inconsistent commands.
9//!
10//! The check is built on top of clap's own `Cli::command()`, so it's
11//! always in sync with the binary you're running. Wire `heddle doctor
12//! docs --all --output json` into CI on every PR to catch doc drift.
13//!
14//! Pure invocation extraction/tokenization lives in
15//! `heddle_core::doctor_docs_plan`.
16
17use std::{
18    collections::{BTreeMap, BTreeSet},
19    path::{Path, PathBuf},
20};
21
22use anyhow::{Result, anyhow};
23use clap::{Command as ClapCommand, CommandFactory};
24use heddle_core::doctor_docs_plan::{
25    DocsInvocation, display_path, extract_invocations, looks_like_value,
26};
27use objects::worktree::should_ignore;
28use serde::Serialize;
29use serde_json::{Map, Value};
30
31use super::{
32    RecoveryAdvice,
33    command_catalog::{
34        ActionTemplate, CommandCatalogOption, CommandCatalogOutput, build_command_catalog,
35        feature_gated_command_roots, recommended_action_template,
36    },
37};
38use crate::cli::{Cli, DoctorDocsArgs, should_output_json};
39
40/// One drift finding.
41#[derive(Debug, Clone, Serialize)]
42pub struct DocsIssue {
43    pub file: String,
44    pub line: usize,
45    pub invocation: String,
46    pub kind: IssueKind,
47    pub detail: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub suggestion: Option<String>,
50}
51
52/// Categories of drift the checker can detect.
53#[derive(Debug, Clone, Copy, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum IssueKind {
56    /// The top-level verb (e.g. `heddle foo`) is not in the CLI.
57    UnknownVerb,
58    /// The subverb (e.g. `heddle thread marker fizz`) is not under that verb.
59    UnknownSubverb,
60    /// A long flag (`--xyz`) is not defined on the resolved (sub)verb.
61    UnknownFlag,
62    /// A literal value passed to `--workspace`/`--scope`/`--kind` is not
63    /// in the valid set for that flag.
64    InvalidFlagValue,
65    /// A syntactically valid or historical command has been removed from the
66    /// supported product vocabulary.
67    RetiredCommand,
68    /// The recommendation sends a source operation through Heddle when durable
69    /// repository authority requires direct Git or a separate workflow step.
70    AuthorityConflict,
71    /// The file referenced by `--path` (or enumerated by `--all`) could
72    /// not be read. We surface this as a real issue, not a silent skip,
73    /// so a typoed path or permission error fails CI instead of letting
74    /// a "scanned 0 files" result pass.
75    Unreadable,
76}
77
78/// Aggregate output for the verb (human and JSON share this shape).
79#[derive(Debug, Clone, Serialize)]
80pub struct DocsReport {
81    pub output_kind: &'static str,
82    pub status: &'static str,
83    #[serde(rename = "verified")]
84    pub verified: bool,
85    pub recommended_action: Option<String>,
86    pub recommended_action_template: Option<ActionTemplate>,
87    pub files_scanned: usize,
88    pub issues: Vec<DocsIssue>,
89}
90
91/// Public entrypoint wired from `main.rs`.
92pub fn cmd_doctor_docs(cli: &Cli, args: DoctorDocsArgs) -> Result<()> {
93    let json = should_output_json(cli, None);
94    let repo_root = cli.repo.clone().map(Ok).unwrap_or_else(|| {
95        std::env::current_dir().map(|cwd| find_repo_root(&cwd).unwrap_or(cwd))
96    })?;
97
98    let files = resolve_files(&repo_root, &args)?;
99    let cli_command = Cli::command();
100    let mut issues = Vec::new();
101    for file in &files {
102        let display = display_path(&repo_root, file);
103        let bytes = match std::fs::read_to_string(file) {
104            Ok(b) => b,
105            Err(err) => {
106                // Unreadable files used to be a silent skip + stderr
107                // log, which let typoed `--path` arguments and missing
108                // files pass CI with a "scanned 0 files" result. Treat
109                // them as real drift findings: the issue list is what
110                // gates the non-zero exit below.
111                issues.push(DocsIssue {
112                    file: display,
113                    line: 0,
114                    invocation: String::new(),
115                    kind: IssueKind::Unreadable,
116                    detail: format!("could not read {}: {}", file.display(), err),
117                    suggestion: None,
118                });
119                continue;
120            }
121        };
122        scan_markdown(&display, &bytes, &cli_command, &mut issues);
123    }
124
125    let clean = issues.is_empty();
126    let recommended_action = (!clean).then(|| "heddle doctor docs --all --output json".to_string());
127    let recommended_action_template = recommended_action
128        .as_deref()
129        .and_then(recommended_action_template);
130    let report = DocsReport {
131        output_kind: "doctor_docs",
132        status: if clean { "clean" } else { "drift" },
133        verified: clean,
134        recommended_action,
135        recommended_action_template,
136        files_scanned: files.len(),
137        issues,
138    };
139
140    if !report.issues.is_empty() {
141        if json {
142            return Err(anyhow!(doctor_docs_drift_advice(&report)?));
143        }
144        render_human(&report);
145        return Err(anyhow!(
146            "{} drift issue(s) found across {} file(s)",
147            report.issues.len(),
148            report.files_scanned
149        ));
150    }
151
152    if json {
153        let s = serde_json::to_string_pretty(&report)?;
154        println!("{s}");
155    } else {
156        render_human(&report);
157    }
158    Ok(())
159}
160
161fn doctor_docs_drift_advice(report: &DocsReport) -> Result<RecoveryAdvice> {
162    let primary = report
163        .recommended_action
164        .clone()
165        .unwrap_or_else(|| "heddle doctor docs --all --output json".to_string());
166    let mut advice = RecoveryAdvice::safety_refusal(
167        "machine_contract_drift",
168        format!(
169            "{} docs drift issue(s) found across {} file(s)",
170            report.issues.len(),
171            report.files_scanned
172        ),
173        format!(
174            "Inspect the issue list in this envelope, then run `{primary}` after updating docs or command metadata."
175        ),
176        format!(
177            "documented Heddle invocations no longer match the registered CLI surface: {} issue(s)",
178            report.issues.len()
179        ),
180        "agents could follow stale commands, flags, or values from the public documentation",
181        "repository state, refs, metadata, and worktree files were left unchanged",
182        primary.clone(),
183        vec![primary],
184    );
185    let mut extra = Map::new();
186    extra.insert(
187        "output_kind".to_string(),
188        Value::String(report.output_kind.to_string()),
189    );
190    extra.insert(
191        "status".to_string(),
192        Value::String(report.status.to_string()),
193    );
194    extra.insert("verified".to_string(), Value::Bool(report.verified));
195    extra.insert(
196        "recommended_action".to_string(),
197        serde_json::to_value(&report.recommended_action)?,
198    );
199    extra.insert(
200        "recommended_action_template".to_string(),
201        serde_json::to_value(&report.recommended_action_template)?,
202    );
203    extra.insert(
204        "files_scanned".to_string(),
205        serde_json::json!(report.files_scanned),
206    );
207    extra.insert("issues".to_string(), serde_json::to_value(&report.issues)?);
208    advice.extra_json_fields = extra;
209    Ok(advice)
210}
211
212fn render_human(report: &DocsReport) {
213    if report.issues.is_empty() {
214        println!(
215            "doctor docs: no drift found across {} file(s)",
216            report.files_scanned
217        );
218        return;
219    }
220    println!(
221        "doctor docs: {} drift issue(s) across {} file(s)",
222        report.issues.len(),
223        report.files_scanned
224    );
225    println!();
226    for issue in &report.issues {
227        println!("{}:{}", issue.file, issue.line);
228        println!("  {}", issue.invocation);
229        println!("  {:?}: {}", issue.kind, issue.detail);
230        if let Some(suggestion) = &issue.suggestion {
231            println!("  suggestion: {}", suggestion);
232        }
233        println!();
234    }
235}
236
237/// Resolve which files to scan. `--path` wins; otherwise (or with
238/// `--all`) we walk the repo root natively and pick up every `.md`
239/// that isn't under a heddle-ignored prefix. Native-heddle repos
240/// have no `.git/`, so shelling out to `git ls-files` (the previous
241/// implementation) hard-failed there. The native walk also lets the
242/// command run in environments without git installed.
243fn resolve_files(repo_root: &Path, args: &DoctorDocsArgs) -> Result<Vec<PathBuf>> {
244    if !args.path.is_empty() && !args.all {
245        return Ok(args
246            .path
247            .iter()
248            .map(|p| {
249                if p.is_absolute() {
250                    p.clone()
251                } else {
252                    repo_root.join(p)
253                }
254            })
255            .collect());
256    }
257    let mut out = Vec::new();
258    walk_markdown(repo_root, repo_root, &mut out)?;
259    out.sort();
260    Ok(out)
261}
262
263/// Common directories the markdown enumerator should never descend
264/// into. Mirrored from `objects::worktree::should_ignore` patterns
265/// the broader codebase already uses; kept inline so this verb works
266/// even in repos that don't ship a `.heddleignore`. Order matches the
267/// surface most likely to bury thousands of irrelevant `.md` files
268/// (build outputs, deps).
269const IGNORE_PATTERNS: &[&str] = &[
270    ".git",
271    ".codex/",
272    ".heddle",
273    ".heddleignore",
274    "target/",
275    "node_modules/",
276    "dist/",
277    "build/",
278    ".venv/",
279    "venv/",
280    ".tox/",
281    ".cache/",
282    ".idea/",
283    ".vscode/",
284    // Spike/design docs deliberately reference planned-but-unbuilt or retired
285    // verbs and flags to argue for a shape. Drift-checking would force every
286    // author to either annotate each example or hold the spike to the current
287    // CLI surface. Exempt the directory wholesale.
288    "docs/spikes/",
289];
290
291/// Markdown files larger than this are skipped — almost always
292/// vendored docs (LICENSEs in markdown, generated API references)
293/// where drift checking would burn time without finding real CLI
294/// invocations. Matches the rough cutoff used by other heddle
295/// scanners. 1 MiB is generous for hand-authored prose.
296const MAX_MARKDOWN_BYTES: u64 = 1024 * 1024;
297
298/// Recursive walk rooted at `dir`. `repo_root` is fixed across the
299/// recursion so we can produce repo-relative paths for the ignore
300/// matcher. We push absolute paths into `out`; sorting happens at
301/// the call site.
302fn walk_markdown(repo_root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
303    let entries = match std::fs::read_dir(dir) {
304        Ok(entries) => entries,
305        // A directory we can't read isn't fatal here — `--all` is a
306        // best-effort enumeration. Surface as a warning and skip; the
307        // unreadable-file path covers the case where a user-supplied
308        // `--path` is missing.
309        Err(err) => {
310            tracing::warn!(
311                dir = %dir.display(),
312                %err,
313                "doctor docs: skipping unreadable directory during --all walk"
314            );
315            return Ok(());
316        }
317    };
318    let ignore_owned: Vec<String> = IGNORE_PATTERNS.iter().map(|s| (*s).to_string()).collect();
319    for entry in entries.flatten() {
320        let path = entry.path();
321        let Ok(rel) = path.strip_prefix(repo_root) else {
322            continue;
323        };
324        if should_ignore(rel, &ignore_owned) {
325            continue;
326        }
327        let file_type = match entry.file_type() {
328            Ok(ft) => ft,
329            Err(_) => continue,
330        };
331        if file_type.is_dir() {
332            walk_markdown(repo_root, &path, out)?;
333            continue;
334        }
335        if !file_type.is_file() {
336            continue;
337        }
338        if path.extension().and_then(|s| s.to_str()) != Some("md") {
339            continue;
340        }
341        // Skip oversize markdown — see `MAX_MARKDOWN_BYTES`.
342        if let Ok(meta) = entry.metadata()
343            && meta.len() > MAX_MARKDOWN_BYTES
344        {
345            continue;
346        }
347        out.push(path);
348    }
349    Ok(())
350}
351
352fn find_repo_root(start: &Path) -> Option<PathBuf> {
353    let mut current = start.to_path_buf();
354    loop {
355        if current.join(".git").exists() || current.join(".heddle").exists() {
356            return Some(current);
357        }
358        if !current.pop() {
359            return None;
360        }
361    }
362}
363
364/// Walk a markdown buffer and emit drift issues into `out`.
365pub fn scan_markdown(
366    display_path: &str,
367    text: &str,
368    cli_command: &ClapCommand,
369    out: &mut Vec<DocsIssue>,
370) {
371    let catalog = build_command_catalog();
372    let historical = is_historical_design_record(display_path);
373    if !historical {
374        scan_forbidden_prose(display_path, text, out);
375    }
376    let invocations = extract_invocations(text);
377    for inv in invocations {
378        if let Some(issue) = forbidden_invocation_issue(display_path, &inv) {
379            if !historical {
380                out.push(issue);
381            }
382            continue;
383        }
384        check_invocation(display_path, &inv, cli_command, &catalog, out);
385    }
386}
387
388fn is_historical_design_record(display_path: &str) -> bool {
389    let normalized = display_path.replace('\\', "/");
390    normalized.starts_with("docs/adr/") || normalized.starts_with("docs/spikes/")
391}
392
393fn scan_forbidden_prose(file: &str, text: &str, out: &mut Vec<DocsIssue>) {
394    let mut in_fence = false;
395    for (index, line) in text.lines().enumerate() {
396        let trimmed = line.trim_start();
397        if trimmed.starts_with("```") {
398            in_fence = !in_fence;
399            continue;
400        }
401        if line.contains('`') {
402            continue;
403        }
404        let lower = line.to_ascii_lowercase();
405        let machine_recommendation = lower.contains("recommended_action")
406            || lower.contains("next_action")
407            || lower.contains("primary_command");
408        let prose_recommendation = ["run heddle ", "use heddle ", "invoke heddle "]
409            .iter()
410            .any(|cue| lower.contains(cue));
411        if (in_fence && !machine_recommendation)
412            || (!in_fence && !machine_recommendation && !prose_recommendation)
413        {
414            continue;
415        }
416        let verb = [
417            "checkpoint",
418            "cherry-pick",
419            "git-overlay",
420            "support",
421            "spool",
422            "prove",
423            "presence",
424            "actor",
425            "session",
426            "switch",
427            "stash",
428            "clean",
429            "fetch",
430            "merge",
431            "rebase",
432        ]
433        .into_iter()
434        .find(|verb| contains_command_phrase(&lower, verb));
435        let land_publishes = contains_command_phrase(&lower, "land")
436            && ["--push", "--no-push", "--publish", "--no-publish"]
437                .iter()
438                .any(|flag| lower.contains(flag));
439        let Some(verb) = verb.or(land_publishes.then_some("land")) else {
440            continue;
441        };
442        let tokens = if verb == "land" {
443            vec!["land".to_string(), "--push".to_string()]
444        } else {
445            vec![verb.to_string()]
446        };
447        let invocation = DocsInvocation {
448            line: index + 1,
449            raw: line.trim().to_string(),
450            tokens,
451        };
452        if let Some(issue) = forbidden_invocation_issue(file, &invocation) {
453            out.push(issue);
454        }
455    }
456}
457
458fn contains_command_phrase(line: &str, verb: &str) -> bool {
459    let phrase = format!("heddle {verb}");
460    line.match_indices(&phrase).any(|(index, _)| {
461        line[index + phrase.len()..]
462            .chars()
463            .next()
464            .is_none_or(|next| !next.is_ascii_alphanumeric() && next != '-' && next != '_')
465    })
466}
467
468fn forbidden_invocation_issue(file: &str, inv: &DocsInvocation) -> Option<DocsIssue> {
469    let verb = inv.tokens.first()?.as_str();
470    let retired = matches!(
471        verb,
472        "checkpoint"
473            | "cherry-pick"
474            | "git-overlay"
475            | "support"
476            | "spool"
477            | "prove"
478            | "presence"
479            | "actor"
480            | "session"
481    );
482    if retired {
483        let suggestion = match verb {
484            "checkpoint" => "use `heddle capture`, then `heddle commit` in Git Overlay",
485            "actor" | "session" => "inspect the `heddle agent` command family",
486            _ => "remove the retired Heddle command recommendation",
487        };
488        return Some(DocsIssue {
489            file: file.to_string(),
490            line: inv.line,
491            invocation: inv.raw.clone(),
492            kind: IssueKind::RetiredCommand,
493            detail: format!("`heddle {verb}` is outside the contracted Heddle CLI"),
494            suggestion: Some(suggestion.to_string()),
495        });
496    }
497
498    if matches!(
499        verb,
500        "switch" | "stash" | "clean" | "fetch" | "merge" | "rebase"
501    ) {
502        return Some(DocsIssue {
503            file: file.to_string(),
504            line: inv.line,
505            invocation: inv.raw.clone(),
506            kind: IssueKind::AuthorityConflict,
507            detail: format!(
508                "`heddle {verb}` is outside Heddle's narrow Git surface; use a compatible client if that operation is required"
509            ),
510            suggestion: Some(format!("use `git {verb}` in Git Overlay documentation")),
511        });
512    }
513
514    if verb == "land"
515        && inv.tokens.iter().skip(1).any(|token| {
516            matches!(
517                token.as_str(),
518                "--push" | "--no-push" | "--publish" | "--no-publish"
519            )
520        })
521    {
522        return Some(DocsIssue {
523            file: file.to_string(),
524            line: inv.line,
525            invocation: inv.raw.clone(),
526            kind: IssueKind::AuthorityConflict,
527            detail: "`heddle land` is local integration and has no publication flags".to_string(),
528            suggestion: Some(
529                "land locally, then publish with the source-authority command".to_string(),
530            ),
531        });
532    }
533
534    None
535}
536
537fn check_invocation(
538    file: &str,
539    inv: &DocsInvocation,
540    cli_command: &ClapCommand,
541    catalog: &CommandCatalogOutput,
542    out: &mut Vec<DocsIssue>,
543) {
544    if inv.tokens.is_empty() {
545        return;
546    }
547    let verb = inv.tokens[0].as_str();
548    // Skip placeholders — `<command>`, `[OPTIONS]`, bare ellipses,
549    // and lone flags can't be resolved as verbs. Also skip tokens
550    // ending in `:` (shell prompt labels, e.g. `$ heddle <state>:`)
551    // and tokens with internal colons (rendered prompts).
552    if verb.starts_with('<')
553        || verb.starts_with('[')
554        || verb.starts_with('{')
555        || verb.starts_with('-')
556        || verb.ends_with(':')
557        || verb == "..."
558        || verb.is_empty()
559    {
560        return;
561    }
562
563    // Verbs that exist in the source but are gated behind a non-default
564    // Cargo feature aren't visible on `Cli::command()` here. Don't
565    // false-positive on docs that describe them — agents and humans
566    // both reach for these surfaces in real builds.
567    if feature_gated_command_roots().contains(&verb) {
568        return;
569    }
570
571    // Resolve verb (and optional subverb) against the clap tree.
572    let Some(verb_cmd) = find_subcommand(cli_command, verb) else {
573        out.push(DocsIssue {
574            file: file.to_string(),
575            line: inv.line,
576            invocation: inv.raw.clone(),
577            kind: IssueKind::UnknownVerb,
578            detail: format!("`heddle {}` is not a known verb", verb),
579            suggestion: suggest_known_alt(cli_command, verb),
580        });
581        return;
582    };
583
584    // Greedily descend through nested subcommands while the next token
585    // looks like an identifier (not a flag, placeholder, or path). We
586    // stop at the first non-identifier or when the current command has
587    // no more subcommands. This handles cases like `heddle context reason
588    // git --path <…>` where the leaf is two levels below the
589    // top-level verb.
590    let mut resolved_cmd = verb_cmd;
591    let mut tokens_used = 1;
592    let mut path_segments = vec![verb_cmd.get_name().to_string()];
593    while tokens_used < inv.tokens.len() {
594        let next = &inv.tokens[tokens_used];
595        if next.starts_with('-')
596            || next.starts_with('<')
597            || next.starts_with('[')
598            || next.contains('/')
599            || looks_like_value(next)
600        {
601            break;
602        }
603        if let Some(sub) = find_subcommand(resolved_cmd, next) {
604            resolved_cmd = sub;
605            path_segments.push(sub.get_name().to_string());
606            tokens_used += 1;
607            continue;
608        }
609        // No match. If the current command has further subcommands,
610        // this is an unknown subverb — flag it. Otherwise (the current
611        // command is a leaf with positional args), just stop and let
612        // flag-checking proceed.
613        if resolved_cmd.get_subcommands().next().is_some() {
614            out.push(DocsIssue {
615                file: file.to_string(),
616                line: inv.line,
617                invocation: inv.raw.clone(),
618                kind: IssueKind::UnknownSubverb,
619                detail: format!(
620                    "`heddle {} {}` is not a known subcommand of `{}`",
621                    path_segments.join(" "),
622                    next,
623                    path_segments.join(" "),
624                ),
625                suggestion: suggest_known_alt(resolved_cmd, next),
626            });
627            return;
628        }
629        break;
630    }
631
632    // Now walk remaining tokens for `--flag` / `--flag=value` shapes.
633    let mut i = tokens_used;
634    let catalog_options = collect_catalog_options(catalog, &path_segments);
635    // The everyday catalog drops `hide = true` args, but they are still part
636    // of the registered CLI contract (e.g. `capture --help-agent`). Recognize
637    // them so docs that reference a hidden-but-real flag don't false-positive
638    // as drift. Closes the class for every hidden flag, not just one.
639    let hidden_long_flags = collect_hidden_long_flags(resolved_cmd);
640    while i < inv.tokens.len() {
641        let tok = &inv.tokens[i];
642        if let Some(flag_body) = tok.strip_prefix("--") {
643            // Skip empty `--` (POSIX end-of-options) and pure placeholders.
644            if flag_body.is_empty() || flag_body.starts_with('<') {
645                i += 1;
646                continue;
647            }
648            let (flag_name, inline_value) = match flag_body.split_once('=') {
649                Some((n, v)) => (n.to_string(), Some(v.to_string())),
650                None => (flag_body.to_string(), None),
651            };
652            // Many docs use `--flag-name>` accidentally; guard.
653            let flag_name = flag_name.trim_end_matches('>').to_string();
654            if !catalog_options.contains_key(&flag_name) {
655                if !hidden_long_flags.contains(&flag_name) {
656                    out.push(DocsIssue {
657                        file: file.to_string(),
658                        line: inv.line,
659                        invocation: inv.raw.clone(),
660                        kind: IssueKind::UnknownFlag,
661                        detail: format!(
662                            "`--{}` is not a flag on `heddle {}`",
663                            flag_name,
664                            path_segments.join(" "),
665                        ),
666                        suggestion: None,
667                    });
668                }
669            } else {
670                // Check known-enum flags. Pull value from inline or
671                // next token (if not a flag/placeholder).
672                let value = match inline_value {
673                    Some(v) => Some(v),
674                    None => inv.tokens.get(i + 1).and_then(|next| {
675                        if next.starts_with('-') || next.starts_with('<') {
676                            None
677                        } else {
678                            Some(next.clone())
679                        }
680                    }),
681                };
682                if let Some(value) = value
683                    && let Some((valid, sug)) =
684                        validate_flag_value(catalog_options[&flag_name], &value)
685                    && !valid
686                {
687                    out.push(DocsIssue {
688                        file: file.to_string(),
689                        line: inv.line,
690                        invocation: inv.raw.clone(),
691                        kind: IssueKind::InvalidFlagValue,
692                        detail: format!("`--{} {}` is not in the valid set", flag_name, value),
693                        suggestion: sug,
694                    });
695                }
696            }
697        }
698        i += 1;
699    }
700}
701
702fn find_subcommand<'a>(cmd: &'a ClapCommand, name: &str) -> Option<&'a ClapCommand> {
703    cmd.get_subcommands().find(|sc| {
704        sc.get_name() == name
705            || sc.get_visible_aliases().any(|alias| alias == name)
706            || sc.get_all_aliases().any(|alias| alias == name)
707    })
708}
709
710fn collect_catalog_options<'a>(
711    catalog: &'a CommandCatalogOutput,
712    path_segments: &[String],
713) -> BTreeMap<String, &'a CommandCatalogOption> {
714    let Some(options) = catalog.options_for_path(path_segments) else {
715        return BTreeMap::new();
716    };
717    options
718        .into_iter()
719        .flat_map(|option| {
720            option
721                .long
722                .iter()
723                .chain(option.aliases.iter())
724                .map(move |name| (name.clone(), option))
725        })
726        .collect()
727}
728
729/// Long flag names (and aliases) of the `hide = true` args on a resolved
730/// command. These are dropped from the everyday command catalog but remain
731/// part of the registered CLI surface, so `doctor docs` must still treat
732/// them as valid flags.
733fn collect_hidden_long_flags(command: &ClapCommand) -> BTreeSet<String> {
734    command
735        .get_arguments()
736        .filter(|arg| arg.is_hide_set())
737        .flat_map(|arg| {
738            arg.get_long()
739                .map(str::to_string)
740                .into_iter()
741                .chain(
742                    arg.get_all_aliases()
743                        .unwrap_or_default()
744                        .into_iter()
745                        .map(str::to_string),
746                )
747                .chain(
748                    arg.get_visible_aliases()
749                        .unwrap_or_default()
750                        .into_iter()
751                        .map(str::to_string),
752                )
753        })
754        .collect()
755}
756
757fn suggest_known_alt(parent: &ClapCommand, _wrong: &str) -> Option<String> {
758    // Cheap "did you mean" surface: just list a couple of close hits.
759    let names: Vec<&str> = parent.get_subcommands().map(|sc| sc.get_name()).collect();
760    if names.is_empty() {
761        return None;
762    }
763    let preview: Vec<&&str> = names.iter().take(6).collect();
764    Some(format!(
765        "known: {}",
766        preview
767            .into_iter()
768            .map(|s| s.to_string())
769            .collect::<Vec<_>>()
770            .join(", ")
771    ))
772}
773
774/// Returns Some((is_valid, suggestion_string)) if `option` advertises finite
775/// values in the command catalog, else None to mean "unchecked".
776fn validate_flag_value(
777    option: &CommandCatalogOption,
778    value: &str,
779) -> Option<(bool, Option<String>)> {
780    if option.possible_values.is_empty()
781        || matches!(option.value_kind.as_str(), "boolean" | "count")
782    {
783        return None;
784    }
785    // Strip placeholder shapes (`<name>`, `"…"`, etc.) — we only validate
786    // literal values.
787    if value.starts_with('<') || value.starts_with('"') || value.starts_with('\'') {
788        return None;
789    }
790    let valid = option
791        .possible_values
792        .iter()
793        .any(|candidate| candidate == value);
794    let suggestion = if valid {
795        None
796    } else {
797        option
798            .long
799            .as_ref()
800            .map(|flag| format!("use --{flag} {{{}}}", option.possible_values.join("|")))
801    };
802    Some((valid, suggestion))
803}
804
805#[cfg(test)]
806mod tests {
807    use clap::CommandFactory;
808
809    use super::*;
810
811    fn cli() -> ClapCommand {
812        Cli::command()
813    }
814
815    fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
816        let Ok(entries) = std::fs::read_dir(dir) else {
817            return;
818        };
819        for entry in entries.flatten() {
820            let path = entry.path();
821            if path.is_dir() {
822                collect_rs_files(&path, out);
823            } else if path.extension().is_some_and(|ext| ext == "rs") {
824                out.push(path);
825            }
826        }
827    }
828
829    // Guard against stale references to folded/deleted verbs in
830    // user-facing source strings (recovery breadcrumbs, advice, help
831    // text, error messages, emitted doc comments). Phase-1 of the CLI
832    // consolidation (heddle#473) folded `gc`/`monitor`/`checkout`/… into
833    // their canonical parents, but free-text strings that still spelled
834    // the old verb slipped past the markdown-only `doctor docs` check —
835    // that scanner only reads `.md` files, not `.rs` sources. We reuse
836    // the exact same backtick-invocation extractor + clap/catalog
837    // resolution against every Rust source file so a future fold can't
838    // leave an invalid `heddle <verb>` behind in an emitted string.
839    //
840    // Validity is data-driven off the live clap command tree +
841    // feature-gated catalog roots — never a hardcoded list — so it stays
842    // correct as verbs are added or removed.
843    #[test]
844    fn source_strings_reference_only_current_top_level_verbs() {
845        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
846        let crates_root = manifest_dir
847            .parent()
848            .expect("cli-contract should live under the workspace crates directory");
849        let src_roots = [manifest_dir.join("src"), crates_root.join("cli/src")];
850        let cli_command = cli();
851
852        // The docs-drift checker's own surfaces intentionally embed
853        // invalid `heddle foo`/`bar`/`frobnicate` examples (enum docs +
854        // negative-test fixtures). Skipping them keeps the guard's
855        // source-of-truth the *real* command set rather than forcing a
856        // denylist of pedagogical tokens.
857        const SELF_TEST_SURFACES: &[&str] = &["doctor_docs.rs", "doctor_schemas.rs"];
858
859        let mut rs_files = Vec::new();
860        for src_root in &src_roots {
861            assert!(
862                src_root.is_dir(),
863                "expected CLI source root at {}",
864                src_root.display()
865            );
866            collect_rs_files(src_root, &mut rs_files);
867        }
868        assert!(
869            rs_files.len() > 50,
870            "expected to walk the cli + cli-contract source trees; only found {} .rs files under {:?}",
871            rs_files.len(),
872            src_roots,
873        );
874
875        let mut stale = Vec::new();
876        for path in rs_files {
877            if SELF_TEST_SURFACES
878                .iter()
879                .any(|name| path.file_name().is_some_and(|f| f == *name))
880            {
881                continue;
882            }
883            let content = std::fs::read_to_string(&path).unwrap();
884            let display = path
885                .strip_prefix(crates_root)
886                .unwrap_or(&path)
887                .display()
888                .to_string();
889            let mut issues = Vec::new();
890            scan_markdown(&display, &content, &cli_command, &mut issues);
891            for issue in issues {
892                // The regression class is folded/deleted *top-level*
893                // verbs (gc/monitor/prune/checkout → maintenance …/
894                // switch). Subverb/flag drift in source strings is a
895                // separate, pre-existing concern and out of scope here.
896                //
897                // A backslash in the captured invocation means the
898                // backtick code-span wrapped across a Rust `\n\` string
899                // continuation, so the line-by-line extractor only saw a
900                // fragment (e.g. `heddle help\n\`). That's a scan
901                // artifact on an otherwise-valid reference, not a stale
902                // verb — a real CLI verb never contains a backslash.
903                if matches!(issue.kind, IssueKind::UnknownVerb) && !issue.invocation.contains('\\')
904                {
905                    stale.push(format!("{}:{} — {}", issue.file, issue.line, issue.detail));
906                }
907            }
908        }
909
910        assert!(
911            stale.is_empty(),
912            "source strings reference verbs that are not current CLI commands \
913             (a folded/deleted verb left a stale `heddle <verb>` reference — \
914             update it to the canonical spelling):\n{}",
915            stale.join("\n"),
916        );
917    }
918
919    #[test]
920    fn detects_invalid_workspace_value() {
921        let mut issues = Vec::new();
922        scan_markdown(
923            "test.md",
924            "Try `heddle start probe --workspace ephemeral` to see.",
925            &cli(),
926            &mut issues,
927        );
928        assert!(
929            issues
930                .iter()
931                .any(|i| matches!(i.kind, IssueKind::InvalidFlagValue)
932                    && i.invocation.contains("--workspace ephemeral"))
933        );
934    }
935
936    #[test]
937    fn detects_invalid_finite_value_from_catalog_metadata() {
938        let mut issues = Vec::new();
939        scan_markdown(
940            "test.md",
941            "Use `heddle context set --path src/lib.rs --kind warning -m note`.",
942            &cli(),
943            &mut issues,
944        );
945        assert!(
946            issues
947                .iter()
948                .any(|i| matches!(i.kind, IssueKind::InvalidFlagValue)
949                    && i.detail.contains("--kind warning")),
950            "expected catalog-derived invalid --kind value, got: {:?}",
951            issues
952        );
953    }
954
955    #[test]
956    fn accepts_global_flags_from_catalog_metadata() {
957        let mut issues = Vec::new();
958        scan_markdown(
959            "test.md",
960            "Inspect `heddle status --output json --repo .`.",
961            &cli(),
962            &mut issues,
963        );
964        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
965    }
966
967    #[test]
968    fn accepts_long_aliases_for_catalog_options() {
969        let mut issues = Vec::new();
970        scan_markdown(
971            "test.md",
972            "Install `heddle integration install --harness-install-scope user`.",
973            &cli(),
974            &mut issues,
975        );
976        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
977    }
978
979    #[test]
980    fn accepts_catalog_option_aliases() {
981        let mut issues = Vec::new();
982        scan_markdown(
983            "test.md",
984            "Install `heddle integration install codex --harness-install-scope repo`.",
985            &cli(),
986            &mut issues,
987        );
988        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
989    }
990
991    #[test]
992    fn does_not_validate_boolean_flags_as_finite_value_options() {
993        let mut issues = Vec::new();
994        scan_markdown(
995            "test.md",
996            "Inspect `heddle log --graph main`.",
997            &cli(),
998            &mut issues,
999        );
1000        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
1001    }
1002
1003    #[test]
1004    fn rejects_retired_commands_even_if_a_stale_parser_variant_survives() {
1005        for invocation in [
1006            "`heddle checkpoint -m save`",
1007            "`heddle actor list`",
1008            "`heddle session start`",
1009        ] {
1010            let mut issues = Vec::new();
1011            scan_markdown("test.md", invocation, &cli(), &mut issues);
1012            assert!(
1013                issues
1014                    .iter()
1015                    .any(|issue| matches!(issue.kind, IssueKind::RetiredCommand)),
1016                "expected retired-command finding for {invocation}: {issues:?}",
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn rejects_retired_commands_in_plain_prose() {
1023        let mut issues = Vec::new();
1024        scan_markdown(
1025            "test.md",
1026            "Agents should run heddle checkpoint before review.",
1027            &cli(),
1028            &mut issues,
1029        );
1030        assert!(
1031            issues
1032                .iter()
1033                .any(|issue| matches!(issue.kind, IssueKind::RetiredCommand)),
1034            "plain prose must not bypass semantic certification: {issues:?}",
1035        );
1036    }
1037
1038    #[test]
1039    fn historical_adr_may_name_retired_checkpoint_but_readme_guidance_may_not() {
1040        let prose = "Migration history: run heddle checkpoint in the old interface.";
1041        let mut adr_issues = Vec::new();
1042        scan_markdown("docs/adr/0000-history.md", prose, &cli(), &mut adr_issues);
1043        assert!(
1044            adr_issues.is_empty(),
1045            "historical design records preserve decision context: {adr_issues:?}",
1046        );
1047
1048        let mut readme_issues = Vec::new();
1049        scan_markdown("README.md", prose, &cli(), &mut readme_issues);
1050        assert!(
1051            readme_issues
1052                .iter()
1053                .any(|issue| matches!(issue.kind, IssueKind::RetiredCommand)),
1054            "current guidance must reject the retired command: {readme_issues:?}",
1055        );
1056    }
1057
1058    #[test]
1059    fn rejects_authority_inconsistent_recommendations() {
1060        for invocation in [
1061            "`heddle fetch origin`",
1062            "`heddle switch main`",
1063            "`heddle land feature --push`",
1064            "`heddle land feature --publish`",
1065        ] {
1066            let mut issues = Vec::new();
1067            scan_markdown("test.md", invocation, &cli(), &mut issues);
1068            assert!(
1069                issues
1070                    .iter()
1071                    .any(|issue| matches!(issue.kind, IssueKind::AuthorityConflict)),
1072                "expected authority-conflict finding for {invocation}: {issues:?}",
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn accepts_native_thread_switch_recommendations() {
1079        let mut issues = Vec::new();
1080        scan_markdown(
1081            "test.md",
1082            "Resume work with `heddle thread switch main`.",
1083            &cli(),
1084            &mut issues,
1085        );
1086        assert!(
1087            issues.is_empty(),
1088            "thread switch is part of Heddle's native thread surface: {issues:?}",
1089        );
1090    }
1091
1092    #[test]
1093    fn does_not_reject_non_finite_context_scope_values() {
1094        let mut issues = Vec::new();
1095        scan_markdown(
1096            "test.md",
1097            "Use `heddle context set --path src/lib.rs --scope symbol:foo --kind rationale -m note`.",
1098            &cli(),
1099            &mut issues,
1100        );
1101        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
1102    }
1103
1104    #[test]
1105    fn detects_unknown_verb() {
1106        let mut issues = Vec::new();
1107        scan_markdown(
1108            "test.md",
1109            "Run `heddle frobnicate --foo`.",
1110            &cli(),
1111            &mut issues,
1112        );
1113        assert!(
1114            issues
1115                .iter()
1116                .any(|i| matches!(i.kind, IssueKind::UnknownVerb))
1117        );
1118    }
1119
1120    #[test]
1121    fn planned_marker_skips_only_next_inline_invocation_line() {
1122        let mut issues = Vec::new();
1123        scan_markdown(
1124            "test.md",
1125            "<!-- doctor-docs:planned -->\n\
1126             Planned command: `heddle frobnicate --foo`.\n\
1127             Real drift: `heddle unsupported --bar`.\n",
1128            &cli(),
1129            &mut issues,
1130        );
1131        assert_eq!(
1132            issues.len(),
1133            1,
1134            "planned marker should skip exactly one content line; got: {:?}",
1135            issues
1136        );
1137        assert!(
1138            issues[0].invocation.contains("heddle unsupported"),
1139            "unmarked drift should remain checked; got: {:?}",
1140            issues
1141        );
1142    }
1143
1144    #[test]
1145    fn planned_marker_skips_fenced_invocations() {
1146        let mut issues = Vec::new();
1147        scan_markdown(
1148            "test.md",
1149            "```sh doctor-docs:planned\n\
1150             heddle frobnicate --foo\n\
1151             ```\n\
1152             `heddle status --output json`\n",
1153            &cli(),
1154            &mut issues,
1155        );
1156        assert!(issues.is_empty(), "expected no drift, got: {:?}", issues);
1157    }
1158
1159    #[test]
1160    fn detects_unknown_flag_on_verb() {
1161        let mut issues = Vec::new();
1162        scan_markdown(
1163            "test.md",
1164            // `--bogus-flag` doesn't exist anywhere; `thread marker delete`
1165            // only takes a positional name plus a `--prefix`.
1166            "Use `heddle thread marker delete --bogus-flag failed-` to clean.",
1167            &cli(),
1168            &mut issues,
1169        );
1170        assert!(
1171            issues
1172                .iter()
1173                .any(|i| matches!(i.kind, IssueKind::UnknownFlag)),
1174            "expected at least one UnknownFlag issue, got: {:?}",
1175            issues
1176        );
1177    }
1178
1179    #[test]
1180    fn hidden_but_registered_flag_is_not_drift() {
1181        // heddle#278 r6 (cid 3327633095): `--help-agent` is `hide = true`,
1182        // so it's dropped from the everyday catalog — but it's still a
1183        // registered clap arg. Docs that reference `heddle capture
1184        // --help-agent` (e.g. personas.md) must NOT be flagged as drift.
1185        let mut issues = Vec::new();
1186        scan_markdown(
1187            "test.md",
1188            "Run `heddle capture --help-agent` to reveal the agent flags.",
1189            &cli(),
1190            &mut issues,
1191        );
1192        assert!(
1193            !issues
1194                .iter()
1195                .any(|i| matches!(i.kind, IssueKind::UnknownFlag)),
1196            "hidden-but-registered `--help-agent` must not be drift, got: {:?}",
1197            issues
1198        );
1199    }
1200
1201    #[test]
1202    fn detects_materialized_misnomer_on_start() {
1203        // The `--materialized` flag was renamed; `heddle start
1204        // --materialized` should now be flagged.
1205        let mut issues = Vec::new();
1206        scan_markdown(
1207            "test.md",
1208            "`heddle start <name> --materialized --path <dir>` is the form.",
1209            &cli(),
1210            &mut issues,
1211        );
1212        assert!(
1213            issues
1214                .iter()
1215                .any(|i| matches!(i.kind, IssueKind::UnknownFlag)),
1216            "expected --materialized to be flagged as unknown, got: {:?}",
1217            issues
1218        );
1219    }
1220
1221    #[test]
1222    fn accepts_valid_invocations() {
1223        let mut issues = Vec::new();
1224        scan_markdown(
1225            "test.md",
1226            "We use `heddle start <name> --path <dir>` here.\n\
1227             Also `heddle context set --path X --scope file --kind rationale -m \"y\"`.\n\
1228             And `heddle thread marker delete failed-build` works fine.\n",
1229            &cli(),
1230            &mut issues,
1231        );
1232        assert!(issues.is_empty(), "expected no issues, got: {:?}", issues);
1233    }
1234
1235    #[test]
1236    fn ignores_prose_mentions() {
1237        let mut issues = Vec::new();
1238        scan_markdown(
1239            "test.md",
1240            "When using heddle context set without proper backticks the checker should ignore.",
1241            &cli(),
1242            &mut issues,
1243        );
1244        assert!(issues.is_empty());
1245    }
1246
1247    #[test]
1248    fn detects_invalid_kind_value() {
1249        let mut issues = Vec::new();
1250        scan_markdown(
1251            "test.md",
1252            "Try `heddle context set --path X --scope file --kind reasoning -m foo`.",
1253            &cli(),
1254            &mut issues,
1255        );
1256        assert!(
1257            issues
1258                .iter()
1259                .any(|i| matches!(i.kind, IssueKind::InvalidFlagValue))
1260        );
1261    }
1262
1263    #[test]
1264    fn parses_fenced_code_block() {
1265        let mut issues = Vec::new();
1266        scan_markdown(
1267            "test.md",
1268            "```bash\nheddle start probe --workspace ephemeral\n```\n",
1269            &cli(),
1270            &mut issues,
1271        );
1272        assert!(
1273            issues
1274                .iter()
1275                .any(|i| matches!(i.kind, IssueKind::InvalidFlagValue))
1276        );
1277    }
1278
1279    #[test]
1280    fn rejects_retired_support_verb() {
1281        let mut issues = Vec::new();
1282        scan_markdown(
1283            "test.md",
1284            "Hosted builds expose `heddle support grant --help` for operators.",
1285            &cli(),
1286            &mut issues,
1287        );
1288        assert!(
1289            issues
1290                .iter()
1291                .any(|issue| matches!(issue.kind, IssueKind::RetiredCommand)),
1292            "got: {issues:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn skips_placeholder_values() {
1298        let mut issues = Vec::new();
1299        scan_markdown(
1300            "test.md",
1301            "Run `heddle start <name> --workspace <mode>`.",
1302            &cli(),
1303            &mut issues,
1304        );
1305        // Should NOT flag `<mode>` as invalid.
1306        assert!(issues.is_empty(), "got: {:?}", issues);
1307    }
1308}