1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::EntityId;
5use memstead_base::Store;
6use memstead_base::ops::{
7 DanglingLink, HealthSummary, health::ConstraintFindingReport, health::HEALTH_INCLUDE_KEYS,
8 health::MissingRequiredOutgoingReport,
9};
10
11use crate::output::{ExitKind, print_json, print_markdown};
12use crate::setup::{CliContext, CliEngine};
13
14#[derive(Parser, Debug)]
18pub struct Args {
19 #[arg(long, value_delimiter = ',')]
71 pub include: Vec<String>,
72
73 #[arg(long)]
76 pub target_schema: Option<String>,
77
78 #[arg(long, default_value_t = 10)]
80 pub limit: usize,
81
82 #[arg(long)]
101 pub strict: bool,
102}
103
104pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
105 let include = &args.include;
106 let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
112
113 let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
118 for key in include {
119 if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
120 include_warnings.push((
121 key.clone(),
122 HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
123 ));
124 }
125 }
126
127 let GatheredHealth {
128 health,
129 real_count,
130 orphan_ids,
131 stub_pairs,
132 community_count,
133 orphans_by_schema,
134 communities_by_schema,
135 most_connected_with_titles,
136 missing_required_outgoing,
137 constraint_findings,
138 schema_format_defects,
139 tag_distribution,
140 dangling_links,
141 findings,
142 body_observations,
143 config_entries,
144 anchors_axis,
145 ledger_axis,
146 open_questions_axis,
147 stale_derivations_axis,
148 checks_axis,
149 signals_axis,
150 labelling_axis,
151 } = match ctx.cli_engine()? {
152 #[cfg(feature = "mem-repo")]
153 CliEngine::MemRepo(mut engine) => {
154 let mut g = gather_mem_repo(&mut engine, args.limit, include);
155 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
156 g.body_observations =
157 gather_body_observations(&engine, include, args.target_schema.as_deref())?;
158 g
159 }
160 CliEngine::Filesystem(mut engine) => {
161 let mut g = gather_filesystem(&mut engine, args.limit, include);
162 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
163 g.body_observations =
164 gather_body_observations(&engine, include, args.target_schema.as_deref())?;
165 g
166 }
167 };
168
169 let mut result = json!({
170 "verdict_coverage": crate::coverage::HEALTH
174 .axis_coverage()
175 .expect("health is a verdict surface")
176 .wire_line(),
177 "summary": {
178 "total_entities": real_count,
179 "total_orphans": orphan_ids.len(),
180 "total_stubs": stub_pairs.len(),
181 "total_stale": health.stale_entities.len(),
182 "total_missing_fields": health.missing_fields.len(),
183 "total_communities": community_count,
184 "orphans_by_schema": orphans_by_schema,
185 "communities_by_schema": communities_by_schema,
186 },
187 });
188 let obj = result.as_object_mut().unwrap();
189
190 if include.iter().any(|s| s == "orphans") {
191 let list: Vec<_> = orphan_ids
192 .iter()
193 .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
194 .collect();
195 obj.insert("orphans".into(), json!(list));
196 }
197 if include.iter().any(|s| s == "stubs") {
198 let list: Vec<_> = stub_pairs
199 .iter()
200 .map(|(id, refs)| {
201 json!({
202 "id": id.to_string(),
203 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
204 })
205 })
206 .collect();
207 obj.insert("stubs".into(), json!(list));
208 }
209 if include.iter().any(|s| s == "most_connected") {
210 let connected: Vec<_> = most_connected_with_titles
211 .iter()
212 .map(
213 |(
214 id,
215 title,
216 total,
217 incoming,
218 outgoing,
219 typed_total,
220 typed_incoming,
221 typed_outgoing,
222 )| {
223 json!({
224 "id": id.to_string(),
225 "title": title,
226 "total": total,
227 "incoming": incoming,
228 "outgoing": outgoing,
229 "typed_total": typed_total,
230 "typed_incoming": typed_incoming,
231 "typed_outgoing": typed_outgoing,
232 })
233 },
234 )
235 .collect();
236 obj.insert("most_connected".into(), json!(connected));
237 }
238 if include.iter().any(|s| s == "missing_fields") {
239 let list: Vec<_> = health
240 .missing_fields
241 .iter()
242 .map(|h| {
243 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
249 let issues: Vec<_> = h
250 .issues
251 .iter()
252 .map(|i| json!({ "field": i.field, "code": i.code, "message": i.message }))
253 .collect();
254 json!({
255 "id": h.id.to_string(),
256 "title": h.title,
257 "missing": missing,
258 "issues": issues,
259 })
260 })
261 .collect();
262 obj.insert("missing_fields".into(), json!(list));
263 }
264 if include.iter().any(|s| s == "stale") {
265 let list: Vec<_> = health
266 .stale_entities
267 .iter()
268 .map(|e| {
269 json!({
270 "id": e.id.to_string(),
271 "title": e.title,
272 "days_since_modified": e.days_since_modified,
273 })
274 })
275 .collect();
276 obj.insert("stale".into(), json!(list));
277 }
278 if include.iter().any(|s| s == "missing_required_outgoing") {
279 if !missing_required_outgoing.is_empty() {
280 strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
281 }
282 obj.insert(
283 "missing_required_outgoing".into(),
284 serde_json::to_value(&missing_required_outgoing)?,
285 );
286 }
287 if include.iter().any(|s| s == "constraints") {
288 if !constraint_findings.is_empty() {
289 strict_violations.push(("constraints", constraint_findings.len()));
290 }
291 obj.insert(
292 "constraints".into(),
293 serde_json::to_value(&constraint_findings)?,
294 );
295 if !schema_format_defects.is_empty() {
298 strict_violations.push(("schema_format_defects", schema_format_defects.len()));
299 obj.insert(
300 "schema_format_defects".into(),
301 serde_json::to_value(&schema_format_defects)?,
302 );
303 }
304 }
305 if include.iter().any(|s| s == "dangling_links") {
306 let arr: Vec<serde_json::Value> = dangling_links
307 .iter()
308 .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
309 .collect();
310 obj.insert("dangling_links".into(), json!(arr));
311 }
312 if include
313 .iter()
314 .any(|s| s == "conformance" || s == "integrity")
315 {
316 if include.iter().any(|s| s == "integrity") {
322 let dangling = findings
327 .iter()
328 .filter(|f| {
329 memstead_base::ops::DanglingLinkKind::ALL_CODES.contains(&f.code.as_str())
330 })
331 .count();
332 if dangling > 0 {
333 strict_violations.push(("dangling_links", dangling));
334 }
335 let orphan_stubs = findings.iter().filter(|f| f.code == "ORPHAN_STUB").count();
336 if orphan_stubs > 0 {
337 strict_violations.push(("orphan_stubs", orphan_stubs));
338 }
339 let ungranted = findings
345 .iter()
346 .filter(|f| f.code == "CROSS_MEM_EDGE_UNGRANTED")
347 .count();
348 if ungranted > 0 {
349 strict_violations.push(("ungranted_cross_mem_edges", ungranted));
350 }
351 }
352 obj.insert("findings".into(), serde_json::to_value(&findings)?);
353 obj.insert(
358 "body_observations".into(),
359 serde_json::to_value(&body_observations)?,
360 );
361 }
362 if include.iter().any(|s| s == "tags")
363 && let Some((distribution, folded, untagged)) = tag_distribution
364 {
365 obj.insert("tag_distribution".into(), distribution);
366 obj.insert("tag_distribution_folded".into(), folded);
367 obj.insert("untagged_entities".into(), untagged);
368 }
369 if let Some(entries) = config_entries {
373 for (k, v) in entries {
374 obj.insert(k, v);
375 }
376 }
377 if let Some(axis) = &anchors_axis {
378 obj.insert("anchors".to_string(), axis.clone());
379 }
380 if let Some(axis) = &ledger_axis {
381 obj.insert("ledger".to_string(), axis.clone());
382 }
383 if let Some(axis) = &open_questions_axis {
384 obj.insert("open_questions".to_string(), axis.clone());
385 }
386 if let Some(axis) = &stale_derivations_axis {
387 obj.insert("stale_derivations".to_string(), axis.clone());
388 }
389 if let Some(axis) = &checks_axis {
390 obj.insert("checks".to_string(), axis.clone());
391 }
392 if let Some(axis) = &signals_axis {
397 if let Some(warn) = axis
398 .get("counts")
399 .and_then(|c| c.get("warn"))
400 .and_then(|w| w.as_u64())
401 && warn > 0
402 {
403 strict_violations.push(("signals", warn as usize));
404 }
405 obj.insert("signals".to_string(), axis.clone());
406 }
407 if let Some(axis) = &labelling_axis {
411 obj.insert("labelling".to_string(), axis.clone());
412 }
413 let friction_axis = if include.iter().any(|s| s == "friction") {
418 let summary = std::env::current_dir()
419 .ok()
420 .and_then(|cwd| crate::setup::find_workspace_root(&cwd))
421 .map(|root| memstead_base::friction::FrictionLedger::for_workspace(&root).summarize())
422 .unwrap_or_else(|| {
423 json!({
424 "total": 0,
425 "by_code": {},
426 "by_verb": {},
427 "recent_24h": { "total": 0, "by_code": {} },
428 "ledger_bytes": 0,
429 })
430 });
431 obj.insert("friction".to_string(), summary.clone());
432 Some(summary)
433 } else {
434 None
435 };
436
437 let mut warning_payload: Vec<serde_json::Value> = health
445 .warnings
446 .iter()
447 .filter_map(|w| serde_json::to_value(w).ok())
448 .collect();
449 warning_payload.extend(include_warnings.iter().map(|(key, allowed)| {
450 json!({
451 "code": "UNKNOWN_INCLUDE_KEY",
452 "message": format!(
453 "unknown include key: \"{key}\". Allowed: {}",
454 allowed.join(", ")
455 ),
456 "details": { "key": key, "allowed": allowed },
457 })
458 }));
459 if !warning_payload.is_empty() {
460 obj.insert("warnings".into(), json!(warning_payload));
461 }
462 if !health.leaf_entities_by_type.is_empty() {
465 obj.insert(
466 "leaf_entities_by_type".into(),
467 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
468 );
469 }
470 if !health.quarantined.is_empty() {
473 obj.insert(
474 "quarantined".into(),
475 serde_json::to_value(&health.quarantined).unwrap_or_default(),
476 );
477 }
478 if !health.load_errors.is_empty() {
484 obj.insert(
485 "load_errors".into(),
486 serde_json::to_value(&health.load_errors).unwrap_or_default(),
487 );
488 }
489 if let Some(diag) = &health.boot_diagnosis {
490 obj.insert("boot_diagnosis".into(), diag.clone());
491 }
492
493 let authoring_drift = health
499 .warnings
500 .iter()
501 .filter(|w| {
502 matches!(
503 w.code(),
504 "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
505 )
506 })
507 .count();
508 if authoring_drift > 0 {
509 strict_violations.push(("schema_authoring_drift", authoring_drift));
510 }
511 for (label, code) in [
519 ("schema_pin_mismatch", "SCHEMA_PIN_MISMATCH"),
520 ("schema_unstamped_source_rot", "SCHEMA_UNSTAMPED_SOURCE_ROT"),
521 ("mount_unbacked", "MOUNT_UNBACKED"),
522 ] {
523 let n = health.warnings.iter().filter(|w| w.code() == code).count();
524 if n > 0 {
525 strict_violations.push((label, n));
526 }
527 }
528
529 if ctx.json {
530 print_json(&result)?;
531 return strict_exit(args.strict, &strict_violations);
532 }
533
534 let mut lines = Vec::new();
536 lines.push("# Graph health".to_string());
537 lines.push(String::new());
538 if let Some(cov) = crate::coverage::HEALTH.axis_coverage() {
541 lines.push(format!("**Verdict coverage:** {}", cov.wire_line()));
542 lines.push(String::new());
543 }
544 lines.push(format!("- Entities: {real_count}"));
545 if orphans_by_schema.len() > 1 {
546 let by: Vec<String> = orphans_by_schema
549 .iter()
550 .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
551 .collect();
552 lines.push(format!(
553 "- Orphans: {} ({})",
554 orphan_ids.len(),
555 by.join(", ")
556 ));
557 } else {
558 lines.push(format!("- Orphans: {}", orphan_ids.len()));
559 }
560 lines.push(format!("- Stubs: {}", stub_pairs.len()));
561 lines.push(format!("- Stale: {}", health.stale_entities.len()));
562 lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
563 lines.push(format!("- Communities: {community_count}"));
564 lines.push(String::new());
565
566 if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
567 lines.push("## Orphans".to_string());
568 for item in v {
569 lines.push(format!(
570 "- {} — {}",
571 item["id"].as_str().unwrap_or(""),
572 item["title"].as_str().unwrap_or("")
573 ));
574 }
575 lines.push(String::new());
576 }
577 if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
578 lines.push("## Stubs".to_string());
579 for item in v {
580 lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
581 }
582 lines.push(String::new());
583 }
584 if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
585 lines.push("## Most connected".to_string());
586 lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
587 for item in v {
588 lines.push(format!(
589 "- {} — {} (typed {}, total {}, in {}, out {})",
590 item["id"].as_str().unwrap_or(""),
591 item["title"].as_str().unwrap_or(""),
592 item["typed_total"].as_u64().unwrap_or(0),
593 item["total"].as_u64().unwrap_or(0),
594 item["incoming"].as_u64().unwrap_or(0),
595 item["outgoing"].as_u64().unwrap_or(0),
596 ));
597 }
598 lines.push(String::new());
599 }
600 if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
601 lines.push("## Missing fields".to_string());
602 for item in v {
603 let labels: Vec<String> = match item["issues"].as_array() {
609 Some(issues) if !issues.is_empty() => issues
610 .iter()
611 .map(|i| {
612 format!(
613 "{} ({})",
614 i["field"].as_str().unwrap_or(""),
615 i["code"].as_str().unwrap_or("MISSING"),
616 )
617 })
618 .collect(),
619 _ => item["missing"]
620 .as_array()
621 .map(|a| {
622 a.iter()
623 .filter_map(|s| s.as_str())
624 .map(str::to_string)
625 .collect()
626 })
627 .unwrap_or_default(),
628 };
629 lines.push(format!(
630 "- {} — {} (issues: {})",
631 item["id"].as_str().unwrap_or(""),
632 item["title"].as_str().unwrap_or(""),
633 labels.join(", ")
634 ));
635 }
636 lines.push(String::new());
637 }
638 if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
639 lines.push("## Stale entities".to_string());
640 for item in v {
641 lines.push(format!(
642 "- {} — {} ({} days)",
643 item["id"].as_str().unwrap_or(""),
644 item["title"].as_str().unwrap_or(""),
645 item["days_since_modified"].as_u64().unwrap_or(0)
646 ));
647 }
648 lines.push(String::new());
649 }
650 if let Some(v) = obj
651 .get("missing_required_outgoing")
652 .and_then(|v| v.as_array())
653 {
654 lines.push("## Missing required outgoing".to_string());
655 for item in v {
656 let blocks: Vec<String> = item["missing"]
657 .as_array()
658 .map(|arr| {
659 arr.iter()
660 .map(|b| {
661 let rels: Vec<&str> = b["relationships"]
662 .as_array()
663 .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
664 .unwrap_or_default();
665 format!(
666 "[{}] {}",
667 rels.join(", "),
668 b["cardinality"].as_str().unwrap_or("")
669 )
670 })
671 .collect()
672 })
673 .unwrap_or_default();
674 lines.push(format!(
675 "- {} — {} (missing: {})",
676 item["id"].as_str().unwrap_or(""),
677 item["title"].as_str().unwrap_or(""),
678 blocks.join("; ")
679 ));
680 }
681 lines.push(String::new());
682 }
683 if let Some(v) = obj.get("findings").and_then(|v| v.as_array()) {
691 lines.push(format!("## Conformance findings ({})", v.len()));
692 if v.is_empty() {
693 lines.push("- none".to_string());
694 }
695 for item in v {
696 let mut line = format!(
697 "- [{}] {} (axis {})",
698 item["code"].as_str().unwrap_or("?"),
699 item["id"].as_str().unwrap_or(""),
700 item["axis"].as_str().unwrap_or("?"),
701 );
702 for key in ["field", "heading", "section"] {
703 if let Some(val) = item["detail"][key].as_str() {
704 line.push_str(&format!(" — {key} `{val}`"));
705 }
706 }
707 lines.push(line);
708 }
709 lines.push(String::new());
710 }
711 if let Some(v) = obj.get("body_observations").and_then(|v| v.as_array())
712 && !v.is_empty()
713 {
714 lines.push(format!("## Body observations ({})", v.len()));
715 for item in v {
716 let mut line = format!(
717 "- [{}] {} — {}",
718 item["code"].as_str().unwrap_or("?"),
719 item["id"].as_str().unwrap_or(""),
720 item["fate"].as_str().unwrap_or("?"),
721 );
722 for key in ["heading", "key"] {
723 if let Some(val) = item["detail"][key].as_str() {
724 line.push_str(&format!(", {key} `{val}`"));
725 }
726 }
727 lines.push(line);
728 }
729 lines.push(String::new());
730 }
731 if let Some(v) = obj.get("constraints").and_then(|v| v.as_array()) {
734 lines.push(format!("## Constraint violations ({})", v.len()));
735 if v.is_empty() {
736 lines.push("- none".to_string());
737 }
738 for item in v {
739 let mut kinds: Vec<String> = item["violations"]
740 .as_array()
741 .map(|a| {
742 a.iter()
743 .filter_map(|x| x["kind"].as_str())
744 .map(str::to_string)
745 .collect()
746 })
747 .unwrap_or_default();
748 if item["format_violations"]
749 .as_array()
750 .is_some_and(|a| !a.is_empty())
751 {
752 kinds.push("section_format".to_string());
753 }
754 lines.push(format!(
755 "- {} — {} ({})",
756 item["id"].as_str().unwrap_or(""),
757 item["title"].as_str().unwrap_or(""),
758 kinds.join(", "),
759 ));
760 }
761 lines.push(String::new());
762 }
763 if let Some(v) = obj.get("schema_format_defects").and_then(|v| v.as_array()) {
764 lines.push(format!("## Schema format defects ({})", v.len()));
765 for item in v {
766 lines.push(format!("- {}", item));
767 }
768 lines.push(String::new());
769 }
770 if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
771 lines.push("## Dangling links".to_string());
772 for item in v {
773 lines.push(format!(
777 "- [{}] {} → {}{}",
778 item["kind"].as_str().unwrap_or("?"),
779 item["from"].as_str().unwrap_or(""),
780 item["target_id"].as_str().unwrap_or(""),
781 item["section"]
782 .as_str()
783 .map(|s| format!(" (in `{s}`)"))
784 .unwrap_or_default(),
785 ));
786 }
787 lines.push(String::new());
788 }
789 if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
790 lines.push("## Tags".to_string());
791 for item in v {
792 lines.push(format!(
793 "- {} ({})",
794 item["tag"].as_str().unwrap_or(""),
795 item["count"].as_u64().unwrap_or(0)
796 ));
797 }
798 lines.push(String::new());
799 }
800 if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
801 lines.push("## Warnings".to_string());
802 for w in v {
803 lines.push(format!(
804 "- {} — {}",
805 w["code"].as_str().unwrap_or(""),
806 w["message"].as_str().unwrap_or("")
807 ));
808 }
809 lines.push(String::new());
810 }
811 if let Some(u) = obj.get("untagged_entities") {
812 lines.push("## Untagged".to_string());
813 lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
814 if let Some(by_type) = u["by_entity_type"].as_object() {
815 let mut entries: Vec<(&String, u64)> = by_type
816 .iter()
817 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
818 .collect();
819 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
820 for (kind, count) in entries {
821 lines.push(format!(" - {kind}: {count}"));
822 }
823 }
824 lines.push(String::new());
825 }
826
827 if let Some(axis) = ledger_axis.as_ref().and_then(|a| a.as_object()) {
832 lines.push(format!("## Ledger vs files ({} folder mem(s))", axis.len()));
833 if axis.is_empty() {
834 lines.push(
835 "- no folder mems: the check does not apply to git-branch storage, whose \
836 change set is a real two-tree diff"
837 .to_string(),
838 );
839 }
840 for (mem, r) in axis {
841 let ghosts = r["ledger_without_file"]
842 .as_array()
843 .map(Vec::len)
844 .unwrap_or(0);
845 let unlogged = r["file_without_ledger"]
846 .as_array()
847 .map(Vec::len)
848 .unwrap_or(0);
849 if ghosts == 0 && unlogged == 0 {
850 lines.push(format!("- `{mem}`: ledger and files agree"));
851 continue;
852 }
853 lines.push(format!(
854 "- `{mem}`: {ghosts} recorded with no file, {unlogged} file(s) the ledger \
855 never mentions"
856 ));
857 for id in r["ledger_without_file"].as_array().into_iter().flatten() {
858 lines.push(format!(
859 " - recorded, no file: `{}`",
860 id.as_str().unwrap_or("")
861 ));
862 }
863 for id in r["file_without_ledger"].as_array().into_iter().flatten() {
864 lines.push(format!(
865 " - file, never recorded: `{}`",
866 id.as_str().unwrap_or("")
867 ));
868 }
869 }
870 lines.push(String::new());
871 }
872
873 if let Some(axis) = anchors_axis.as_ref().and_then(|a| a.as_object()) {
874 lines.push(format!("## Anchors ({} mems)", axis.len()));
875 for (mem, counts) in axis {
876 lines.push(format!(
884 "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable (artifact gone) \
885 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
886 counts["resolved"].as_u64().unwrap_or(0),
887 counts["drifted"].as_u64().unwrap_or(0),
888 counts["recheck"].as_u64().unwrap_or(0),
889 counts["unresolvable"].as_u64().unwrap_or(0),
890 counts["unobserved"].as_u64().unwrap_or(0),
891 counts["dangling"].as_u64().unwrap_or(0),
892 counts["population"]
893 .as_str()
894 .unwrap_or("population not stated"),
895 ));
896 }
897 lines.push(String::new());
898 }
899
900 if let Some(axis) = open_questions_axis.as_ref().and_then(|a| a.as_object()) {
901 let cap = axis
902 .get("_item_cap")
903 .and_then(|v| v.as_u64())
904 .unwrap_or_default();
905 lines.push(format!("## Open questions (item cap {cap} per kind)"));
906 for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
907 let total = entry["total_open"].as_u64().unwrap_or(0);
908 lines.push(format!("- `{mem}`: {total} open"));
909 for kind in [
910 "stubs",
911 "anchors_recheck",
912 "anchors_unresolvable",
913 "anchors_unobserved",
917 "anchors_dangling",
922 "unsatisfied_constraints",
923 "dangling_links",
924 ] {
925 let count = entry[kind]["count"].as_u64().unwrap_or(0);
926 if count > 0 {
927 let more = entry[kind]["more"].as_u64().unwrap_or(0);
928 let suffix = if more > 0 {
929 format!(" ({more} more not shown)")
930 } else {
931 String::new()
932 };
933 lines.push(format!(" - {kind}: {count}{suffix}"));
934 }
935 }
936 if let Some(process) = entry.get("process").and_then(|p| p.as_array()) {
937 for p in process {
938 if p["resolvable"] == serde_json::json!(true) {
939 lines.push(format!(
940 " - process `{}`: {} open entries; {} already searched (do not redo)",
941 p["binding"].as_str().unwrap_or("?"),
942 p["open_entries"]["count"].as_u64().unwrap_or(0),
943 p["already_searched"]["count"].as_u64().unwrap_or(0),
944 ));
945 } else {
946 lines.push(format!(
947 " - process `{}`: not resolvable (mem not mounted)",
948 p["binding"].as_str().unwrap_or("?"),
949 ));
950 }
951 }
952 }
953 }
954 lines.push(String::new());
955 }
956
957 if let Some(axis) = checks_axis.as_ref().and_then(|a| a.as_object()) {
962 lines.push(format!("## Checks ({} mems)", axis.len()));
963 for (mem, c) in axis {
964 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
965 let conf = |key: &str| {
966 c.get("conformance")
967 .and_then(|g| g.get(key))
968 .and_then(|x| x.as_u64())
969 .unwrap_or(0)
970 };
971 let gate = |key: &str| {
972 c.get("independence")
973 .and_then(|g| g.get(key))
974 .and_then(|e| e.get("count"))
975 .and_then(|x| x.as_u64())
976 .unwrap_or(0)
977 };
978 lines.push(format!(
979 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
980 check_stale {}; conformance: never_checked {}, \
981 checked_ok {}, check_failed {}, check_stale {}; \
982 independence: self_checked {}, \
983 confirmed_independent {}, unconfirmable {}",
984 count("never_checked"),
985 count("checked_ok"),
986 count("check_failed"),
987 count("check_stale"),
988 conf("never_checked"),
989 conf("checked_ok"),
990 conf("check_failed"),
991 conf("check_stale"),
992 gate("self_checked"),
993 gate("confirmed_independent"),
994 gate("unconfirmable"),
995 ));
996 }
997 lines.push(String::new());
998 }
999
1000 if let Some(axis) = obj.get("signals") {
1002 lines.push(format!(
1003 "## Signals (notice {}, warn {})",
1004 axis["counts"]["notice"].as_u64().unwrap_or(0),
1005 axis["counts"]["warn"].as_u64().unwrap_or(0),
1006 ));
1007 for e in axis["entities"].as_array().into_iter().flatten() {
1008 for s in e["signals"].as_array().into_iter().flatten() {
1009 let contributors = s["contributors"]
1010 .as_array()
1011 .map(|a| {
1012 a.iter()
1013 .filter_map(|c| c.as_str())
1014 .collect::<Vec<_>>()
1015 .join(", ")
1016 })
1017 .unwrap_or_default();
1018 lines.push(format!(
1019 "- {} — {}: {} ({}) [{}]",
1020 e["id"].as_str().unwrap_or(""),
1021 s["name"].as_str().unwrap_or(""),
1022 s["value"].as_u64().unwrap_or(0),
1023 s["level"].as_str().unwrap_or(""),
1024 contributors,
1025 ));
1026 }
1027 }
1028 lines.push(String::new());
1029 }
1030
1031 if let Some(axis) = obj.get("labelling").and_then(|a| a.as_object()) {
1033 lines.push(format!("## Labelling ({} mems)", axis.len()));
1034 for (mem, m) in axis {
1035 let c = &m["counts"];
1036 lines.push(format!(
1037 "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
1038 c["accepted"].as_u64().unwrap_or(0),
1039 c["defeated"].as_u64().unwrap_or(0),
1040 c["undecided"].as_u64().unwrap_or(0),
1041 m["cross_mem_edges_excluded"].as_u64().unwrap_or(0),
1042 ));
1043 for d in m["defeated"].as_array().into_iter().flatten() {
1044 let by = d["defeated_by"]
1045 .as_array()
1046 .map(|a| {
1047 a.iter()
1048 .filter_map(|x| x.as_str())
1049 .collect::<Vec<_>>()
1050 .join(", ")
1051 })
1052 .unwrap_or_default();
1053 lines.push(format!(
1054 " - defeated: {} (by {by})",
1055 d["id"].as_str().unwrap_or("")
1056 ));
1057 }
1058 for u in m["undecided"].as_array().into_iter().flatten() {
1059 let by = u["undecided_by"]
1060 .as_array()
1061 .map(|a| {
1062 a.iter()
1063 .filter_map(|x| x.as_str())
1064 .collect::<Vec<_>>()
1065 .join(", ")
1066 })
1067 .unwrap_or_default();
1068 lines.push(format!(
1069 " - undecided: {} (open attackers {by})",
1070 u["id"].as_str().unwrap_or("")
1071 ));
1072 }
1073 }
1074 lines.push(String::new());
1075 }
1076
1077 if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
1080 let total: usize = axis
1081 .values()
1082 .filter_map(|a| a.as_array().map(|a| a.len()))
1083 .sum();
1084 lines.push(format!("## Stale derivations ({total} findings)"));
1085 for (mem, findings) in axis {
1086 for f in findings.as_array().into_iter().flatten() {
1087 lines.push(format!(
1088 "- `{mem}`: {} -[{}]-> {} ({})",
1089 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1090 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1091 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1092 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1093 ));
1094 }
1095 }
1096 lines.push(String::new());
1097 }
1098
1099 if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
1104 lines.push(format!("## Quarantined mems ({})", arr.len()));
1105 for q in arr {
1106 lines.push(format!(
1107 "- `{}` [{}] {}",
1108 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1109 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1110 q.get("reason_message")
1111 .and_then(|x| x.as_str())
1112 .unwrap_or(""),
1113 ));
1114 }
1115 lines.push(String::new());
1116 }
1117
1118 if let Some(arr) = obj.get("load_errors").and_then(|v| v.as_array()) {
1121 lines.push(format!("## Load errors ({})", arr.len()));
1122 for e in arr {
1123 lines.push(format!(
1124 "- `{}` — {}",
1125 e.get("file").and_then(|x| x.as_str()).unwrap_or(""),
1126 e.get("error").and_then(|x| x.as_str()).unwrap_or(""),
1127 ));
1128 }
1129 lines.push(String::new());
1130 }
1131
1132 if let Some(f) = &friction_axis {
1133 lines.push(format!(
1134 "## Friction ({} refusals recorded, {} in the last 24h)",
1135 f["total"].as_u64().unwrap_or(0),
1136 f["recent_24h"]["total"].as_u64().unwrap_or(0),
1137 ));
1138 if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
1139 lines.push("- by code:".to_string());
1140 let mut entries: Vec<(&String, u64)> = by_code
1141 .iter()
1142 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1143 .collect();
1144 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1145 for (code, count) in entries {
1146 lines.push(format!(" - {code}: {count}"));
1147 if let Some(reasons) = f["by_reason"][code.as_str()]
1150 .as_object()
1151 .filter(|m| !m.is_empty())
1152 {
1153 let mut rs: Vec<(&String, u64)> = reasons
1154 .iter()
1155 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1156 .collect();
1157 rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1158 for (reason, count) in rs {
1159 lines.push(format!(" - {reason}: {count}"));
1160 }
1161 }
1162 }
1163 }
1164 if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
1165 lines.push("- by verb:".to_string());
1166 let mut entries: Vec<(&String, u64)> = by_verb
1167 .iter()
1168 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1169 .collect();
1170 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1171 for (verb, count) in entries {
1172 lines.push(format!(" - {verb}: {count}"));
1173 }
1174 }
1175 lines.push(String::new());
1176 }
1177
1178 print_markdown(&lines.join("\n"));
1179 strict_exit(args.strict, &strict_violations)
1180}
1181
1182type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
1188
1189struct GatheredHealth {
1193 health: HealthSummary,
1194 findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
1198 body_observations: Vec<memstead_base::ops::integrity::BodyObservation>,
1202 real_count: usize,
1203 orphan_ids: Vec<(EntityId, String)>,
1206 stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
1207 community_count: usize,
1208 orphans_by_schema: std::collections::BTreeMap<String, usize>,
1213 communities_by_schema: std::collections::BTreeMap<String, usize>,
1214 most_connected_with_titles: Vec<MostConnectedRow>,
1216 missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
1217 constraint_findings: Vec<ConstraintFindingReport>,
1220 schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
1223 tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
1233 dangling_links: Vec<DanglingLink>,
1237 config_entries: Option<serde_json::Map<String, serde_json::Value>>,
1244 anchors_axis: Option<serde_json::Value>,
1251 ledger_axis: Option<serde_json::Value>,
1253 open_questions_axis: Option<serde_json::Value>,
1257 stale_derivations_axis: Option<serde_json::Value>,
1261 checks_axis: Option<serde_json::Value>,
1265 signals_axis: Option<serde_json::Value>,
1268 labelling_axis: Option<serde_json::Value>,
1272}
1273
1274fn gather_findings(
1280 engine: &memstead_base::Engine,
1281 include: &[String],
1282 target_schema: Option<&str>,
1283) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
1284 let wants_conformance = include
1285 .iter()
1286 .any(|s| s == "conformance" || s == "integrity");
1287 if !wants_conformance {
1288 return Ok(Vec::new());
1289 }
1290 let target: Option<memstead_schema::SchemaRef> = match target_schema {
1291 None => None,
1292 Some(raw) => Some(
1293 raw.parse::<memstead_schema::SchemaRef>()
1294 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1295 ),
1296 };
1297 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1298 mems.sort();
1299 let mut findings = Vec::new();
1300 for v in &mems {
1301 findings.extend(
1302 engine
1303 .conformance_findings(v, target.as_ref())
1304 .map_err(crate::CliError::from_engine_op)?,
1305 );
1306 if include.iter().any(|s| s == "integrity") {
1307 findings.extend(
1308 engine
1309 .consistency_findings(v)
1310 .map_err(crate::CliError::from_engine_op)?,
1311 );
1312 }
1313 }
1314 Ok(findings)
1315}
1316
1317fn gather_body_observations(
1327 engine: &memstead_base::Engine,
1328 include: &[String],
1329 target_schema: Option<&str>,
1330) -> anyhow::Result<Vec<memstead_base::ops::integrity::BodyObservation>> {
1331 if !include
1332 .iter()
1333 .any(|s| s == "conformance" || s == "integrity")
1334 {
1335 return Ok(Vec::new());
1336 }
1337 let target = match target_schema {
1338 None => None,
1339 Some(raw) => Some(
1340 raw.parse::<memstead_schema::SchemaRef>()
1341 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1342 ),
1343 };
1344 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1345 mems.sort();
1346 let mut out = Vec::new();
1347 for v in &mems {
1348 out.extend(
1349 engine
1350 .body_observations(v, target.as_ref())
1351 .map_err(crate::CliError::from_engine_op)?,
1352 );
1353 }
1354 Ok(out)
1355}
1356
1357#[cfg(feature = "mem-repo")]
1358fn gather_mem_repo(
1359 engine: &mut memstead_base::Engine,
1360 limit: usize,
1361 include: &[String],
1362) -> GatheredHealth {
1363 let mut g = gather_from_store(
1364 engine.health(),
1365 engine.store(),
1366 engine.communities().count,
1367 limit,
1368 include,
1369 || engine.orphans(),
1370 |limit| engine_most_connected_mem_repo(engine, limit),
1371 || engine.missing_required_outgoing(None),
1372 || engine.constraint_findings(None),
1373 || engine.schema_format_defects(),
1374 );
1375 fill_schema_breakdowns(engine, &mut g);
1376 fill_config_projection(engine, include, &mut g);
1377 fill_anchors_axis(engine, include, &mut g);
1378 fill_open_questions_axis(engine, include, &mut g);
1379 fill_stale_derivations_axis(engine, include, &mut g);
1380 fill_checks_axis(engine, include, &mut g);
1381 fill_signals_axis(engine, include, &mut g);
1382 fill_labelling_axis(engine, include, &mut g);
1383 g
1384}
1385
1386fn gather_filesystem(
1387 engine: &mut memstead_base::Engine,
1388 limit: usize,
1389 include: &[String],
1390) -> GatheredHealth {
1391 let mut g = gather_from_store(
1392 engine.health(),
1393 engine.store(),
1394 engine.communities().count,
1395 limit,
1396 include,
1397 || engine.orphans(),
1398 |limit| engine_most_connected_filesystem(engine, limit),
1399 || engine.missing_required_outgoing(None),
1400 || engine.constraint_findings(None),
1401 || engine.schema_format_defects(),
1402 );
1403 fill_schema_breakdowns(engine, &mut g);
1404 fill_config_projection(engine, include, &mut g);
1405 fill_anchors_axis(engine, include, &mut g);
1406 fill_open_questions_axis(engine, include, &mut g);
1407 fill_stale_derivations_axis(engine, include, &mut g);
1408 fill_checks_axis(engine, include, &mut g);
1409 fill_signals_axis(engine, include, &mut g);
1410 fill_labelling_axis(engine, include, &mut g);
1411 g
1412}
1413
1414fn fill_config_projection(
1420 engine: &memstead_base::Engine,
1421 include: &[String],
1422 g: &mut GatheredHealth,
1423) {
1424 if include.iter().any(|s| s == "config") {
1425 let mut mems: Vec<String> = engine
1426 .mem_router()
1427 .writable_mems()
1428 .iter()
1429 .cloned()
1430 .collect();
1431 mems.sort();
1432 let (mutations, plugin) =
1433 memstead_base::ops::health::config_projection_from_settings(engine.settings());
1434 g.config_entries = Some(memstead_base::ops::health::config_projection(
1435 engine, &mems, mutations, plugin,
1436 ));
1437 }
1438}
1439
1440fn fill_open_questions_axis(
1446 engine: &memstead_base::Engine,
1447 include: &[String],
1448 g: &mut GatheredHealth,
1449) {
1450 if include.iter().any(|s| s == "open_questions") {
1451 g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
1452 engine, None,
1453 ));
1454 }
1455}
1456
1457fn fill_stale_derivations_axis(
1461 engine: &memstead_base::Engine,
1462 include: &[String],
1463 g: &mut GatheredHealth,
1464) {
1465 if include.iter().any(|s| s == "stale_derivations") {
1466 g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
1467 engine, None,
1468 ));
1469 }
1470}
1471
1472fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1473 if include.iter().any(|s| s == "checks") {
1474 g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1475 }
1476}
1477
1478fn fill_signals_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1479 if include.iter().any(|s| s == "signals") {
1480 g.signals_axis = Some(engine.health_signals_axis(None));
1481 }
1482}
1483
1484fn fill_labelling_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1485 if include.iter().any(|s| s == "labelling") {
1486 g.labelling_axis = Some(engine.health_labelling_axis(None));
1487 }
1488}
1489
1490fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1491 if include.iter().any(|s| s == "anchors") {
1492 g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1493 }
1494 if include.iter().any(|s| s == "ledger") {
1497 g.ledger_axis = serde_json::to_value(engine.ledger_reconciliation()).ok();
1498 }
1499}
1500
1501fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1502 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1503 g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1504 g.communities_by_schema = engine.communities_by_schema(&mems);
1505}
1506
1507#[allow(clippy::too_many_arguments)]
1515fn gather_from_store(
1516 health: HealthSummary,
1517 store: &Store,
1518 community_count: usize,
1519 limit: usize,
1520 include: &[String],
1521 orphans_fn: impl FnOnce() -> Vec<EntityId>,
1522 most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1523 missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1524 constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1525 schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1526) -> GatheredHealth {
1527 let real_count = store.all_entities().filter(|e| !e.stub).count();
1528 let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1529 .into_iter()
1530 .map(|id| {
1531 let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1532 (id, title)
1533 })
1534 .collect();
1535 let stub_pairs = memstead_base::graph::query::find_stubs(store);
1536 let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1537 most_connected_fn(limit)
1538 } else {
1539 Vec::new()
1540 };
1541 let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1542 missing_required_outgoing_fn()
1543 } else {
1544 Vec::new()
1545 };
1546 let constraint_findings = if include.iter().any(|s| s == "constraints") {
1547 constraint_findings_fn()
1548 } else {
1549 Vec::new()
1550 };
1551 let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1552 schema_format_defects_fn()
1553 } else {
1554 Vec::new()
1555 };
1556 let tag_distribution = if include.iter().any(|s| s == "tags") {
1557 let (distribution, folded, untagged) =
1558 memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1559 Some((
1560 serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1561 serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1562 serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1563 ))
1564 } else {
1565 None
1566 };
1567 let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1568 memstead_base::ops::health::collect_dangling_links(store, None)
1569 } else {
1570 Vec::new()
1571 };
1572 GatheredHealth {
1573 ledger_axis: None,
1574 health,
1575 findings: Vec::new(),
1576 real_count,
1577 orphan_ids,
1578 stub_pairs,
1579 community_count,
1580 orphans_by_schema: std::collections::BTreeMap::new(),
1583 communities_by_schema: std::collections::BTreeMap::new(),
1584 most_connected_with_titles,
1585 missing_required_outgoing,
1586 constraint_findings,
1587 schema_format_defects,
1588 tag_distribution,
1589 dangling_links,
1590 body_observations: Vec::new(),
1591 config_entries: None,
1592 anchors_axis: None,
1593 open_questions_axis: None,
1594 stale_derivations_axis: None,
1595 checks_axis: None,
1596 signals_axis: None,
1597 labelling_axis: None,
1598 }
1599}
1600
1601#[cfg(feature = "mem-repo")]
1602fn engine_most_connected_mem_repo(
1603 engine: &memstead_base::Engine,
1604 limit: usize,
1605) -> Vec<MostConnectedRow> {
1606 engine
1607 .most_connected(limit)
1608 .into_iter()
1609 .map(|c| {
1610 let title = engine
1611 .get_entity(&c.id)
1612 .map(|e| e.title.clone())
1613 .unwrap_or_default();
1614 (
1615 c.id,
1616 title,
1617 c.total,
1618 c.incoming,
1619 c.outgoing,
1620 c.typed_total,
1621 c.typed_incoming,
1622 c.typed_outgoing,
1623 )
1624 })
1625 .collect()
1626}
1627
1628fn engine_most_connected_filesystem(
1629 engine: &memstead_base::Engine,
1630 limit: usize,
1631) -> Vec<MostConnectedRow> {
1632 engine
1633 .most_connected(limit)
1634 .into_iter()
1635 .map(|c| {
1636 let title = engine
1637 .get_entity(&c.id)
1638 .map(|e| e.title.clone())
1639 .unwrap_or_default();
1640 (
1641 c.id,
1642 title,
1643 c.total,
1644 c.incoming,
1645 c.outgoing,
1646 c.typed_total,
1647 c.typed_incoming,
1648 c.typed_outgoing,
1649 )
1650 })
1651 .collect()
1652}
1653
1654fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1660 if !strict || violations.is_empty() {
1661 return Ok(());
1662 }
1663 let summary = violations
1664 .iter()
1665 .map(|(code, n)| format!("{code}: {n}"))
1666 .collect::<Vec<_>>()
1667 .join(", ");
1668 Err(crate::CliError::new(
1669 ExitKind::Generic,
1670 "HEALTH_STRICT_VIOLATIONS",
1671 format!("strict mode: tier-2 violations present ({summary})"),
1672 )
1673 .into())
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678 use super::*;
1679 use clap::CommandFactory;
1680
1681 #[test]
1682 fn help_lists_every_include_key() {
1683 let cmd = Args::command();
1684 let arg = cmd
1685 .get_arguments()
1686 .find(|a| a.get_id() == "include")
1687 .expect("--include arg must exist");
1688 let help = arg
1689 .get_help()
1690 .expect("--include must have help text")
1691 .to_string();
1692 for key in HEALTH_INCLUDE_KEYS {
1693 assert!(
1694 help.contains(key),
1695 "`memstead health --help` must name include key `{key}` (got: {help})"
1696 );
1697 }
1698 }
1699}