1use 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#[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#[derive(Debug, Clone, Copy, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum IssueKind {
56 UnknownVerb,
58 UnknownSubverb,
60 UnknownFlag,
62 InvalidFlagValue,
65 RetiredCommand,
68 AuthorityConflict,
71 Unreadable,
76}
77
78#[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
91pub 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 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
237fn 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
263const 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 "docs/spikes/",
289];
290
291const MAX_MARKDOWN_BYTES: u64 = 1024 * 1024;
297
298fn 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 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 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
364pub 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 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 if feature_gated_command_roots().contains(&verb) {
568 return;
569 }
570
571 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 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 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 let mut i = tokens_used;
634 let catalog_options = collect_catalog_options(catalog, &path_segments);
635 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 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 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 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
729fn 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 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
774fn 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 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 #[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 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 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 "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 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 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 assert!(issues.is_empty(), "got: {:?}", issues);
1307 }
1308}