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 if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
1000 && !foreign.is_empty()
1001 {
1002 let listed: Vec<String> = foreign
1003 .iter()
1004 .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
1005 .collect();
1006 lines.push(format!(" - foreign kinds: {}", listed.join(", ")));
1007 }
1008 if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
1009 for (entity, f) in findings {
1010 let code = f["finding"]["code"].as_str().unwrap_or("?");
1011 let section = f["finding"]["section"]
1012 .as_str()
1013 .map(|s| format!(" [{s}]"))
1014 .unwrap_or_default();
1015 let message = f["finding"]["message"].as_str().unwrap_or("");
1016 lines.push(format!(
1017 " - finding on `{entity}` ({} {}): {code}{section} — {message}",
1018 f["kind"].as_str().unwrap_or("verification"),
1019 f["verdict"].as_str().unwrap_or("?"),
1020 ));
1021 }
1022 }
1023 }
1024 lines.push(String::new());
1025 }
1026
1027 if let Some(axis) = obj.get("signals") {
1029 lines.push(format!(
1030 "## Signals (notice {}, warn {})",
1031 axis["counts"]["notice"].as_u64().unwrap_or(0),
1032 axis["counts"]["warn"].as_u64().unwrap_or(0),
1033 ));
1034 for e in axis["entities"].as_array().into_iter().flatten() {
1035 for s in e["signals"].as_array().into_iter().flatten() {
1036 let contributors = s["contributors"]
1037 .as_array()
1038 .map(|a| {
1039 a.iter()
1040 .filter_map(|c| c.as_str())
1041 .collect::<Vec<_>>()
1042 .join(", ")
1043 })
1044 .unwrap_or_default();
1045 lines.push(format!(
1046 "- {} — {}: {} ({}) [{}]",
1047 e["id"].as_str().unwrap_or(""),
1048 s["name"].as_str().unwrap_or(""),
1049 s["value"].as_u64().unwrap_or(0),
1050 s["level"].as_str().unwrap_or(""),
1051 contributors,
1052 ));
1053 }
1054 }
1055 lines.push(String::new());
1056 }
1057
1058 if let Some(axis) = obj.get("labelling").and_then(|a| a.as_object()) {
1060 lines.push(format!("## Labelling ({} mems)", axis.len()));
1061 for (mem, m) in axis {
1062 let c = &m["counts"];
1063 lines.push(format!(
1064 "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
1065 c["accepted"].as_u64().unwrap_or(0),
1066 c["defeated"].as_u64().unwrap_or(0),
1067 c["undecided"].as_u64().unwrap_or(0),
1068 m["cross_mem_edges_excluded"].as_u64().unwrap_or(0),
1069 ));
1070 for d in m["defeated"].as_array().into_iter().flatten() {
1071 let by = d["defeated_by"]
1072 .as_array()
1073 .map(|a| {
1074 a.iter()
1075 .filter_map(|x| x.as_str())
1076 .collect::<Vec<_>>()
1077 .join(", ")
1078 })
1079 .unwrap_or_default();
1080 lines.push(format!(
1081 " - defeated: {} (by {by})",
1082 d["id"].as_str().unwrap_or("")
1083 ));
1084 }
1085 for u in m["undecided"].as_array().into_iter().flatten() {
1086 let by = u["undecided_by"]
1087 .as_array()
1088 .map(|a| {
1089 a.iter()
1090 .filter_map(|x| x.as_str())
1091 .collect::<Vec<_>>()
1092 .join(", ")
1093 })
1094 .unwrap_or_default();
1095 lines.push(format!(
1096 " - undecided: {} (open attackers {by})",
1097 u["id"].as_str().unwrap_or("")
1098 ));
1099 }
1100 }
1101 lines.push(String::new());
1102 }
1103
1104 if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
1107 let total: usize = axis
1108 .values()
1109 .filter_map(|a| a.as_array().map(|a| a.len()))
1110 .sum();
1111 lines.push(format!("## Stale derivations ({total} findings)"));
1112 for (mem, findings) in axis {
1113 for f in findings.as_array().into_iter().flatten() {
1114 lines.push(format!(
1115 "- `{mem}`: {} -[{}]-> {} ({})",
1116 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1117 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1118 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1119 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1120 ));
1121 }
1122 }
1123 lines.push(String::new());
1124 }
1125
1126 if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
1131 lines.push(format!("## Quarantined mems ({})", arr.len()));
1132 for q in arr {
1133 lines.push(format!(
1134 "- `{}` [{}] {}",
1135 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1136 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1137 q.get("reason_message")
1138 .and_then(|x| x.as_str())
1139 .unwrap_or(""),
1140 ));
1141 }
1142 lines.push(String::new());
1143 }
1144
1145 if let Some(arr) = obj.get("load_errors").and_then(|v| v.as_array()) {
1148 lines.push(format!("## Load errors ({})", arr.len()));
1149 for e in arr {
1150 lines.push(format!(
1151 "- `{}` — {}",
1152 e.get("file").and_then(|x| x.as_str()).unwrap_or(""),
1153 e.get("error").and_then(|x| x.as_str()).unwrap_or(""),
1154 ));
1155 }
1156 lines.push(String::new());
1157 }
1158
1159 if let Some(f) = &friction_axis {
1160 lines.push(format!(
1161 "## Friction ({} refusals recorded, {} in the last 24h)",
1162 f["total"].as_u64().unwrap_or(0),
1163 f["recent_24h"]["total"].as_u64().unwrap_or(0),
1164 ));
1165 if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
1166 lines.push("- by code:".to_string());
1167 let mut entries: Vec<(&String, u64)> = by_code
1168 .iter()
1169 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1170 .collect();
1171 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1172 for (code, count) in entries {
1173 lines.push(format!(" - {code}: {count}"));
1174 if let Some(reasons) = f["by_reason"][code.as_str()]
1177 .as_object()
1178 .filter(|m| !m.is_empty())
1179 {
1180 let mut rs: Vec<(&String, u64)> = reasons
1181 .iter()
1182 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1183 .collect();
1184 rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1185 for (reason, count) in rs {
1186 lines.push(format!(" - {reason}: {count}"));
1187 }
1188 }
1189 }
1190 }
1191 if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
1192 lines.push("- by verb:".to_string());
1193 let mut entries: Vec<(&String, u64)> = by_verb
1194 .iter()
1195 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
1196 .collect();
1197 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1198 for (verb, count) in entries {
1199 lines.push(format!(" - {verb}: {count}"));
1200 }
1201 }
1202 lines.push(String::new());
1203 }
1204
1205 print_markdown(&lines.join("\n"));
1206 strict_exit(args.strict, &strict_violations)
1207}
1208
1209type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
1215
1216struct GatheredHealth {
1220 health: HealthSummary,
1221 findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
1225 body_observations: Vec<memstead_base::ops::integrity::BodyObservation>,
1229 real_count: usize,
1230 orphan_ids: Vec<(EntityId, String)>,
1233 stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
1234 community_count: usize,
1235 orphans_by_schema: std::collections::BTreeMap<String, usize>,
1240 communities_by_schema: std::collections::BTreeMap<String, usize>,
1241 most_connected_with_titles: Vec<MostConnectedRow>,
1243 missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
1244 constraint_findings: Vec<ConstraintFindingReport>,
1247 schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
1250 tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
1260 dangling_links: Vec<DanglingLink>,
1264 config_entries: Option<serde_json::Map<String, serde_json::Value>>,
1271 anchors_axis: Option<serde_json::Value>,
1278 ledger_axis: Option<serde_json::Value>,
1280 open_questions_axis: Option<serde_json::Value>,
1284 stale_derivations_axis: Option<serde_json::Value>,
1288 checks_axis: Option<serde_json::Value>,
1292 signals_axis: Option<serde_json::Value>,
1295 labelling_axis: Option<serde_json::Value>,
1299}
1300
1301fn gather_findings(
1307 engine: &memstead_base::Engine,
1308 include: &[String],
1309 target_schema: Option<&str>,
1310) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
1311 let wants_conformance = include
1312 .iter()
1313 .any(|s| s == "conformance" || s == "integrity");
1314 if !wants_conformance {
1315 return Ok(Vec::new());
1316 }
1317 let target: Option<memstead_schema::SchemaRef> = match target_schema {
1318 None => None,
1319 Some(raw) => Some(
1320 raw.parse::<memstead_schema::SchemaRef>()
1321 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1322 ),
1323 };
1324 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1325 mems.sort();
1326 let mut findings = Vec::new();
1327 for v in &mems {
1328 findings.extend(
1329 engine
1330 .conformance_findings(v, target.as_ref())
1331 .map_err(crate::CliError::from_engine_op)?,
1332 );
1333 if include.iter().any(|s| s == "integrity") {
1334 findings.extend(
1335 engine
1336 .consistency_findings(v)
1337 .map_err(crate::CliError::from_engine_op)?,
1338 );
1339 }
1340 }
1341 Ok(findings)
1342}
1343
1344fn gather_body_observations(
1354 engine: &memstead_base::Engine,
1355 include: &[String],
1356 target_schema: Option<&str>,
1357) -> anyhow::Result<Vec<memstead_base::ops::integrity::BodyObservation>> {
1358 if !include
1359 .iter()
1360 .any(|s| s == "conformance" || s == "integrity")
1361 {
1362 return Ok(Vec::new());
1363 }
1364 let target = match target_schema {
1365 None => None,
1366 Some(raw) => Some(
1367 raw.parse::<memstead_schema::SchemaRef>()
1368 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1369 ),
1370 };
1371 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1372 mems.sort();
1373 let mut out = Vec::new();
1374 for v in &mems {
1375 out.extend(
1376 engine
1377 .body_observations(v, target.as_ref())
1378 .map_err(crate::CliError::from_engine_op)?,
1379 );
1380 }
1381 Ok(out)
1382}
1383
1384#[cfg(feature = "mem-repo")]
1385fn gather_mem_repo(
1386 engine: &mut memstead_base::Engine,
1387 limit: usize,
1388 include: &[String],
1389) -> GatheredHealth {
1390 let mut g = gather_from_store(
1391 engine.health(),
1392 engine.store(),
1393 engine.communities().count,
1394 limit,
1395 include,
1396 || engine.orphans(),
1397 |limit| engine_most_connected_mem_repo(engine, limit),
1398 || engine.missing_required_outgoing(None),
1399 || engine.constraint_findings(None),
1400 || engine.schema_format_defects(),
1401 );
1402 fill_schema_breakdowns(engine, &mut g);
1403 fill_config_projection(engine, include, &mut g);
1404 fill_anchors_axis(engine, include, &mut g);
1405 fill_open_questions_axis(engine, include, &mut g);
1406 fill_stale_derivations_axis(engine, include, &mut g);
1407 fill_checks_axis(engine, include, &mut g);
1408 fill_signals_axis(engine, include, &mut g);
1409 fill_labelling_axis(engine, include, &mut g);
1410 g
1411}
1412
1413fn gather_filesystem(
1414 engine: &mut memstead_base::Engine,
1415 limit: usize,
1416 include: &[String],
1417) -> GatheredHealth {
1418 let mut g = gather_from_store(
1419 engine.health(),
1420 engine.store(),
1421 engine.communities().count,
1422 limit,
1423 include,
1424 || engine.orphans(),
1425 |limit| engine_most_connected_filesystem(engine, limit),
1426 || engine.missing_required_outgoing(None),
1427 || engine.constraint_findings(None),
1428 || engine.schema_format_defects(),
1429 );
1430 fill_schema_breakdowns(engine, &mut g);
1431 fill_config_projection(engine, include, &mut g);
1432 fill_anchors_axis(engine, include, &mut g);
1433 fill_open_questions_axis(engine, include, &mut g);
1434 fill_stale_derivations_axis(engine, include, &mut g);
1435 fill_checks_axis(engine, include, &mut g);
1436 fill_signals_axis(engine, include, &mut g);
1437 fill_labelling_axis(engine, include, &mut g);
1438 g
1439}
1440
1441fn fill_config_projection(
1447 engine: &memstead_base::Engine,
1448 include: &[String],
1449 g: &mut GatheredHealth,
1450) {
1451 if include.iter().any(|s| s == "config") {
1452 let mut mems: Vec<String> = engine
1453 .mem_router()
1454 .writable_mems()
1455 .iter()
1456 .cloned()
1457 .collect();
1458 mems.sort();
1459 let (mutations, plugin) =
1460 memstead_base::ops::health::config_projection_from_settings(engine.settings());
1461 g.config_entries = Some(memstead_base::ops::health::config_projection(
1462 engine, &mems, mutations, plugin,
1463 ));
1464 }
1465}
1466
1467fn fill_open_questions_axis(
1473 engine: &memstead_base::Engine,
1474 include: &[String],
1475 g: &mut GatheredHealth,
1476) {
1477 if include.iter().any(|s| s == "open_questions") {
1478 g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
1479 engine, None,
1480 ));
1481 }
1482}
1483
1484fn fill_stale_derivations_axis(
1488 engine: &memstead_base::Engine,
1489 include: &[String],
1490 g: &mut GatheredHealth,
1491) {
1492 if include.iter().any(|s| s == "stale_derivations") {
1493 g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
1494 engine, None,
1495 ));
1496 }
1497}
1498
1499fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1500 if include.iter().any(|s| s == "checks") {
1501 g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1502 }
1503}
1504
1505fn fill_signals_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1506 if include.iter().any(|s| s == "signals") {
1507 g.signals_axis = Some(engine.health_signals_axis(None));
1508 }
1509}
1510
1511fn fill_labelling_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1512 if include.iter().any(|s| s == "labelling") {
1513 g.labelling_axis = Some(engine.health_labelling_axis(None));
1514 }
1515}
1516
1517fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1518 if include.iter().any(|s| s == "anchors") {
1519 g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1520 }
1521 if include.iter().any(|s| s == "ledger") {
1524 g.ledger_axis = serde_json::to_value(engine.ledger_reconciliation()).ok();
1525 }
1526}
1527
1528fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1529 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1530 g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1531 g.communities_by_schema = engine.communities_by_schema(&mems);
1532}
1533
1534#[allow(clippy::too_many_arguments)]
1542fn gather_from_store(
1543 health: HealthSummary,
1544 store: &Store,
1545 community_count: usize,
1546 limit: usize,
1547 include: &[String],
1548 orphans_fn: impl FnOnce() -> Vec<EntityId>,
1549 most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1550 missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1551 constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1552 schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1553) -> GatheredHealth {
1554 let real_count = store.all_entities().filter(|e| !e.stub).count();
1555 let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1556 .into_iter()
1557 .map(|id| {
1558 let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1559 (id, title)
1560 })
1561 .collect();
1562 let stub_pairs = memstead_base::graph::query::find_stubs(store);
1563 let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1564 most_connected_fn(limit)
1565 } else {
1566 Vec::new()
1567 };
1568 let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1569 missing_required_outgoing_fn()
1570 } else {
1571 Vec::new()
1572 };
1573 let constraint_findings = if include.iter().any(|s| s == "constraints") {
1574 constraint_findings_fn()
1575 } else {
1576 Vec::new()
1577 };
1578 let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1579 schema_format_defects_fn()
1580 } else {
1581 Vec::new()
1582 };
1583 let tag_distribution = if include.iter().any(|s| s == "tags") {
1584 let (distribution, folded, untagged) =
1585 memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1586 Some((
1587 serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1588 serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1589 serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1590 ))
1591 } else {
1592 None
1593 };
1594 let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1595 memstead_base::ops::health::collect_dangling_links(store, None)
1596 } else {
1597 Vec::new()
1598 };
1599 GatheredHealth {
1600 ledger_axis: None,
1601 health,
1602 findings: Vec::new(),
1603 real_count,
1604 orphan_ids,
1605 stub_pairs,
1606 community_count,
1607 orphans_by_schema: std::collections::BTreeMap::new(),
1610 communities_by_schema: std::collections::BTreeMap::new(),
1611 most_connected_with_titles,
1612 missing_required_outgoing,
1613 constraint_findings,
1614 schema_format_defects,
1615 tag_distribution,
1616 dangling_links,
1617 body_observations: Vec::new(),
1618 config_entries: None,
1619 anchors_axis: None,
1620 open_questions_axis: None,
1621 stale_derivations_axis: None,
1622 checks_axis: None,
1623 signals_axis: None,
1624 labelling_axis: None,
1625 }
1626}
1627
1628#[cfg(feature = "mem-repo")]
1629fn engine_most_connected_mem_repo(
1630 engine: &memstead_base::Engine,
1631 limit: usize,
1632) -> Vec<MostConnectedRow> {
1633 engine
1634 .most_connected(limit)
1635 .into_iter()
1636 .map(|c| {
1637 let title = engine
1638 .get_entity(&c.id)
1639 .map(|e| e.title.clone())
1640 .unwrap_or_default();
1641 (
1642 c.id,
1643 title,
1644 c.total,
1645 c.incoming,
1646 c.outgoing,
1647 c.typed_total,
1648 c.typed_incoming,
1649 c.typed_outgoing,
1650 )
1651 })
1652 .collect()
1653}
1654
1655fn engine_most_connected_filesystem(
1656 engine: &memstead_base::Engine,
1657 limit: usize,
1658) -> Vec<MostConnectedRow> {
1659 engine
1660 .most_connected(limit)
1661 .into_iter()
1662 .map(|c| {
1663 let title = engine
1664 .get_entity(&c.id)
1665 .map(|e| e.title.clone())
1666 .unwrap_or_default();
1667 (
1668 c.id,
1669 title,
1670 c.total,
1671 c.incoming,
1672 c.outgoing,
1673 c.typed_total,
1674 c.typed_incoming,
1675 c.typed_outgoing,
1676 )
1677 })
1678 .collect()
1679}
1680
1681fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1687 if !strict || violations.is_empty() {
1688 return Ok(());
1689 }
1690 let summary = violations
1691 .iter()
1692 .map(|(code, n)| format!("{code}: {n}"))
1693 .collect::<Vec<_>>()
1694 .join(", ");
1695 Err(crate::CliError::new(
1696 ExitKind::Generic,
1697 "HEALTH_STRICT_VIOLATIONS",
1698 format!("strict mode: tier-2 violations present ({summary})"),
1699 )
1700 .into())
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705 use super::*;
1706 use clap::CommandFactory;
1707
1708 #[test]
1709 fn help_lists_every_include_key() {
1710 let cmd = Args::command();
1711 let arg = cmd
1712 .get_arguments()
1713 .find(|a| a.get_id() == "include")
1714 .expect("--include arg must exist");
1715 let help = arg
1716 .get_help()
1717 .expect("--include must have help text")
1718 .to_string();
1719 for key in HEALTH_INCLUDE_KEYS {
1720 assert!(
1721 help.contains(key),
1722 "`memstead health --help` must name include key `{key}` (got: {help})"
1723 );
1724 }
1725 }
1726}