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 = ',')]
61 pub include: Vec<String>,
62
63 #[arg(long)]
66 pub target_schema: Option<String>,
67
68 #[arg(long, default_value_t = 10)]
70 pub limit: usize,
71
72 #[arg(long)]
88 pub strict: bool,
89}
90
91pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
92 let include = &args.include;
93 let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
99
100 let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
105 for key in include {
106 if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
107 include_warnings.push((
108 key.clone(),
109 HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
110 ));
111 }
112 }
113
114 let GatheredHealth {
115 health,
116 real_count,
117 orphan_ids,
118 stub_pairs,
119 community_count,
120 orphans_by_schema,
121 communities_by_schema,
122 most_connected_with_titles,
123 missing_required_outgoing,
124 constraint_findings,
125 schema_format_defects,
126 tag_distribution,
127 dangling_links,
128 findings,
129 config_entries,
130 anchors_axis,
131 open_questions_axis,
132 stale_derivations_axis,
133 checks_axis,
134 signals_axis,
135 labelling_axis,
136 } = match ctx.cli_engine()? {
137 #[cfg(feature = "mem-repo")]
138 CliEngine::MemRepo(mut engine) => {
139 let mut g = gather_mem_repo(&mut engine, args.limit, include);
140 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
141 g
142 }
143 CliEngine::Filesystem(mut engine) => {
144 let mut g = gather_filesystem(&mut engine, args.limit, include);
145 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
146 g
147 }
148 };
149
150 let mut result = json!({
151 "summary": {
152 "total_entities": real_count,
153 "total_orphans": orphan_ids.len(),
154 "total_stubs": stub_pairs.len(),
155 "total_stale": health.stale_entities.len(),
156 "total_missing_fields": health.missing_fields.len(),
157 "total_communities": community_count,
158 "orphans_by_schema": orphans_by_schema,
159 "communities_by_schema": communities_by_schema,
160 },
161 });
162 let obj = result.as_object_mut().unwrap();
163
164 if include.iter().any(|s| s == "orphans") {
165 let list: Vec<_> = orphan_ids
166 .iter()
167 .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
168 .collect();
169 obj.insert("orphans".into(), json!(list));
170 }
171 if include.iter().any(|s| s == "stubs") {
172 let list: Vec<_> = stub_pairs
173 .iter()
174 .map(|(id, refs)| {
175 json!({
176 "id": id.to_string(),
177 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
178 })
179 })
180 .collect();
181 obj.insert("stubs".into(), json!(list));
182 }
183 if include.iter().any(|s| s == "most_connected") {
184 let connected: Vec<_> = most_connected_with_titles
185 .iter()
186 .map(
187 |(
188 id,
189 title,
190 total,
191 incoming,
192 outgoing,
193 typed_total,
194 typed_incoming,
195 typed_outgoing,
196 )| {
197 json!({
198 "id": id.to_string(),
199 "title": title,
200 "total": total,
201 "incoming": incoming,
202 "outgoing": outgoing,
203 "typed_total": typed_total,
204 "typed_incoming": typed_incoming,
205 "typed_outgoing": typed_outgoing,
206 })
207 },
208 )
209 .collect();
210 obj.insert("most_connected".into(), json!(connected));
211 }
212 if include.iter().any(|s| s == "missing_fields") {
213 let list: Vec<_> = health
214 .missing_fields
215 .iter()
216 .map(|h| {
217 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
223 let issues: Vec<_> = h
224 .issues
225 .iter()
226 .map(|i| json!({ "field": i.field, "code": i.code, "message": i.message }))
227 .collect();
228 json!({
229 "id": h.id.to_string(),
230 "title": h.title,
231 "missing": missing,
232 "issues": issues,
233 })
234 })
235 .collect();
236 obj.insert("missing_fields".into(), json!(list));
237 }
238 if include.iter().any(|s| s == "stale") {
239 let list: Vec<_> = health
240 .stale_entities
241 .iter()
242 .map(|e| {
243 json!({
244 "id": e.id.to_string(),
245 "title": e.title,
246 "days_since_modified": e.days_since_modified,
247 })
248 })
249 .collect();
250 obj.insert("stale".into(), json!(list));
251 }
252 if include.iter().any(|s| s == "missing_required_outgoing") {
253 if !missing_required_outgoing.is_empty() {
254 strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
255 }
256 obj.insert(
257 "missing_required_outgoing".into(),
258 serde_json::to_value(&missing_required_outgoing)?,
259 );
260 }
261 if include.iter().any(|s| s == "constraints") {
262 if !constraint_findings.is_empty() {
263 strict_violations.push(("constraints", constraint_findings.len()));
264 }
265 obj.insert(
266 "constraints".into(),
267 serde_json::to_value(&constraint_findings)?,
268 );
269 if !schema_format_defects.is_empty() {
272 strict_violations.push(("schema_format_defects", schema_format_defects.len()));
273 obj.insert(
274 "schema_format_defects".into(),
275 serde_json::to_value(&schema_format_defects)?,
276 );
277 }
278 }
279 if include.iter().any(|s| s == "dangling_links") {
280 let arr: Vec<serde_json::Value> = dangling_links
281 .iter()
282 .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
283 .collect();
284 obj.insert("dangling_links".into(), json!(arr));
285 }
286 if include
287 .iter()
288 .any(|s| s == "conformance" || s == "integrity")
289 {
290 if include.iter().any(|s| s == "integrity") {
296 let dangling = findings
297 .iter()
298 .filter(|f| f.code == "DANGLING_LINK")
299 .count();
300 if dangling > 0 {
301 strict_violations.push(("dangling_links", dangling));
302 }
303 let orphan_stubs = findings.iter().filter(|f| f.code == "ORPHAN_STUB").count();
304 if orphan_stubs > 0 {
305 strict_violations.push(("orphan_stubs", orphan_stubs));
306 }
307 }
308 obj.insert("findings".into(), serde_json::to_value(&findings)?);
309 }
310 if include.iter().any(|s| s == "tags")
311 && let Some((distribution, folded, untagged)) = tag_distribution
312 {
313 obj.insert("tag_distribution".into(), distribution);
314 obj.insert("tag_distribution_folded".into(), folded);
315 obj.insert("untagged_entities".into(), untagged);
316 }
317 if let Some(entries) = config_entries {
321 for (k, v) in entries {
322 obj.insert(k, v);
323 }
324 }
325 if let Some(axis) = &anchors_axis {
326 obj.insert("anchors".to_string(), axis.clone());
327 }
328 if let Some(axis) = &open_questions_axis {
329 obj.insert("open_questions".to_string(), axis.clone());
330 }
331 if let Some(axis) = &stale_derivations_axis {
332 obj.insert("stale_derivations".to_string(), axis.clone());
333 }
334 if let Some(axis) = &checks_axis {
335 obj.insert("checks".to_string(), axis.clone());
336 }
337 if let Some(axis) = &signals_axis {
342 if let Some(warn) = axis
343 .get("counts")
344 .and_then(|c| c.get("warn"))
345 .and_then(|w| w.as_u64())
346 && warn > 0
347 {
348 strict_violations.push(("signals", warn as usize));
349 }
350 obj.insert("signals".to_string(), axis.clone());
351 }
352 if let Some(axis) = &labelling_axis {
356 obj.insert("labelling".to_string(), axis.clone());
357 }
358 let friction_axis = if include.iter().any(|s| s == "friction") {
363 let summary = std::env::current_dir()
364 .ok()
365 .and_then(|cwd| crate::setup::find_workspace_root(&cwd))
366 .map(|root| memstead_base::friction::FrictionLedger::for_workspace(&root).summarize())
367 .unwrap_or_else(|| {
368 json!({
369 "total": 0,
370 "by_code": {},
371 "by_verb": {},
372 "recent_24h": { "total": 0, "by_code": {} },
373 "ledger_bytes": 0,
374 })
375 });
376 obj.insert("friction".to_string(), summary.clone());
377 Some(summary)
378 } else {
379 None
380 };
381
382 let mut warning_payload: Vec<serde_json::Value> = health
390 .warnings
391 .iter()
392 .filter_map(|w| serde_json::to_value(w).ok())
393 .collect();
394 warning_payload.extend(include_warnings.iter().map(|(key, allowed)| {
395 json!({
396 "code": "UNKNOWN_INCLUDE_KEY",
397 "message": format!(
398 "unknown include key: \"{key}\". Allowed: {}",
399 allowed.join(", ")
400 ),
401 "details": { "key": key, "allowed": allowed },
402 })
403 }));
404 if !warning_payload.is_empty() {
405 obj.insert("warnings".into(), json!(warning_payload));
406 }
407 if !health.leaf_entities_by_type.is_empty() {
410 obj.insert(
411 "leaf_entities_by_type".into(),
412 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
413 );
414 }
415 if !health.quarantined.is_empty() {
418 obj.insert(
419 "quarantined".into(),
420 serde_json::to_value(&health.quarantined).unwrap_or_default(),
421 );
422 }
423 if !health.load_errors.is_empty() {
429 obj.insert(
430 "load_errors".into(),
431 serde_json::to_value(&health.load_errors).unwrap_or_default(),
432 );
433 }
434 if let Some(diag) = &health.boot_diagnosis {
435 obj.insert("boot_diagnosis".into(), diag.clone());
436 }
437
438 let authoring_drift = health
444 .warnings
445 .iter()
446 .filter(|w| {
447 matches!(
448 w.code(),
449 "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
450 )
451 })
452 .count();
453 if authoring_drift > 0 {
454 strict_violations.push(("schema_authoring_drift", authoring_drift));
455 }
456 for (label, code) in [
464 ("schema_pin_mismatch", "SCHEMA_PIN_MISMATCH"),
465 ("schema_unstamped_source_rot", "SCHEMA_UNSTAMPED_SOURCE_ROT"),
466 ("mount_unbacked", "MOUNT_UNBACKED"),
467 ] {
468 let n = health.warnings.iter().filter(|w| w.code() == code).count();
469 if n > 0 {
470 strict_violations.push((label, n));
471 }
472 }
473
474 if ctx.json {
475 print_json(&result)?;
476 return strict_exit(args.strict, &strict_violations);
477 }
478
479 let mut lines = Vec::new();
481 lines.push("# Graph health".to_string());
482 lines.push(String::new());
483 lines.push(format!("- Entities: {real_count}"));
484 if orphans_by_schema.len() > 1 {
485 let by: Vec<String> = orphans_by_schema
488 .iter()
489 .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
490 .collect();
491 lines.push(format!(
492 "- Orphans: {} ({})",
493 orphan_ids.len(),
494 by.join(", ")
495 ));
496 } else {
497 lines.push(format!("- Orphans: {}", orphan_ids.len()));
498 }
499 lines.push(format!("- Stubs: {}", stub_pairs.len()));
500 lines.push(format!("- Stale: {}", health.stale_entities.len()));
501 lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
502 lines.push(format!("- Communities: {community_count}"));
503 lines.push(String::new());
504
505 if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
506 lines.push("## Orphans".to_string());
507 for item in v {
508 lines.push(format!(
509 "- {} — {}",
510 item["id"].as_str().unwrap_or(""),
511 item["title"].as_str().unwrap_or("")
512 ));
513 }
514 lines.push(String::new());
515 }
516 if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
517 lines.push("## Stubs".to_string());
518 for item in v {
519 lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
520 }
521 lines.push(String::new());
522 }
523 if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
524 lines.push("## Most connected".to_string());
525 lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
526 for item in v {
527 lines.push(format!(
528 "- {} — {} (typed {}, total {}, in {}, out {})",
529 item["id"].as_str().unwrap_or(""),
530 item["title"].as_str().unwrap_or(""),
531 item["typed_total"].as_u64().unwrap_or(0),
532 item["total"].as_u64().unwrap_or(0),
533 item["incoming"].as_u64().unwrap_or(0),
534 item["outgoing"].as_u64().unwrap_or(0),
535 ));
536 }
537 lines.push(String::new());
538 }
539 if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
540 lines.push("## Missing fields".to_string());
541 for item in v {
542 let labels: Vec<String> = match item["issues"].as_array() {
548 Some(issues) if !issues.is_empty() => issues
549 .iter()
550 .map(|i| {
551 format!(
552 "{} ({})",
553 i["field"].as_str().unwrap_or(""),
554 i["code"].as_str().unwrap_or("MISSING"),
555 )
556 })
557 .collect(),
558 _ => item["missing"]
559 .as_array()
560 .map(|a| {
561 a.iter()
562 .filter_map(|s| s.as_str())
563 .map(str::to_string)
564 .collect()
565 })
566 .unwrap_or_default(),
567 };
568 lines.push(format!(
569 "- {} — {} (issues: {})",
570 item["id"].as_str().unwrap_or(""),
571 item["title"].as_str().unwrap_or(""),
572 labels.join(", ")
573 ));
574 }
575 lines.push(String::new());
576 }
577 if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
578 lines.push("## Stale entities".to_string());
579 for item in v {
580 lines.push(format!(
581 "- {} — {} ({} days)",
582 item["id"].as_str().unwrap_or(""),
583 item["title"].as_str().unwrap_or(""),
584 item["days_since_modified"].as_u64().unwrap_or(0)
585 ));
586 }
587 lines.push(String::new());
588 }
589 if let Some(v) = obj
590 .get("missing_required_outgoing")
591 .and_then(|v| v.as_array())
592 {
593 lines.push("## Missing required outgoing".to_string());
594 for item in v {
595 let blocks: Vec<String> = item["missing"]
596 .as_array()
597 .map(|arr| {
598 arr.iter()
599 .map(|b| {
600 let rels: Vec<&str> = b["relationships"]
601 .as_array()
602 .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
603 .unwrap_or_default();
604 format!(
605 "[{}] {}",
606 rels.join(", "),
607 b["cardinality"].as_str().unwrap_or("")
608 )
609 })
610 .collect()
611 })
612 .unwrap_or_default();
613 lines.push(format!(
614 "- {} — {} (missing: {})",
615 item["id"].as_str().unwrap_or(""),
616 item["title"].as_str().unwrap_or(""),
617 blocks.join("; ")
618 ));
619 }
620 lines.push(String::new());
621 }
622 if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
623 lines.push("## Dangling links".to_string());
624 for item in v {
625 lines.push(format!(
626 "- {} → {} (section: {})",
627 item["from"].as_str().unwrap_or(""),
628 item["target_id"].as_str().unwrap_or(""),
629 item["section"].as_str().unwrap_or("(none)")
630 ));
631 }
632 lines.push(String::new());
633 }
634 if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
635 lines.push("## Tags".to_string());
636 for item in v {
637 lines.push(format!(
638 "- {} ({})",
639 item["tag"].as_str().unwrap_or(""),
640 item["count"].as_u64().unwrap_or(0)
641 ));
642 }
643 lines.push(String::new());
644 }
645 if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
646 lines.push("## Warnings".to_string());
647 for w in v {
648 lines.push(format!(
649 "- {} — {}",
650 w["code"].as_str().unwrap_or(""),
651 w["message"].as_str().unwrap_or("")
652 ));
653 }
654 lines.push(String::new());
655 }
656 if let Some(u) = obj.get("untagged_entities") {
657 lines.push("## Untagged".to_string());
658 lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
659 if let Some(by_type) = u["by_entity_type"].as_object() {
660 let mut entries: Vec<(&String, u64)> = by_type
661 .iter()
662 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
663 .collect();
664 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
665 for (kind, count) in entries {
666 lines.push(format!(" - {kind}: {count}"));
667 }
668 }
669 lines.push(String::new());
670 }
671
672 if let Some(axis) = anchors_axis.as_ref().and_then(|a| a.as_object()) {
673 lines.push(format!("## Anchors ({} mems)", axis.len()));
674 for (mem, counts) in axis {
675 lines.push(format!(
676 "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable {}",
677 counts["resolved"].as_u64().unwrap_or(0),
678 counts["drifted"].as_u64().unwrap_or(0),
679 counts["recheck"].as_u64().unwrap_or(0),
680 counts["unresolvable"].as_u64().unwrap_or(0),
681 ));
682 }
683 lines.push(String::new());
684 }
685
686 if let Some(axis) = open_questions_axis.as_ref().and_then(|a| a.as_object()) {
687 let cap = axis
688 .get("_item_cap")
689 .and_then(|v| v.as_u64())
690 .unwrap_or_default();
691 lines.push(format!("## Open questions (item cap {cap} per kind)"));
692 for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
693 let total = entry["total_open"].as_u64().unwrap_or(0);
694 lines.push(format!("- `{mem}`: {total} open"));
695 for kind in [
696 "stubs",
697 "anchors_recheck",
698 "anchors_unresolvable",
699 "unsatisfied_constraints",
700 "dangling_links",
701 ] {
702 let count = entry[kind]["count"].as_u64().unwrap_or(0);
703 if count > 0 {
704 let more = entry[kind]["more"].as_u64().unwrap_or(0);
705 let suffix = if more > 0 {
706 format!(" ({more} more not shown)")
707 } else {
708 String::new()
709 };
710 lines.push(format!(" - {kind}: {count}{suffix}"));
711 }
712 }
713 if let Some(process) = entry.get("process").and_then(|p| p.as_array()) {
714 for p in process {
715 if p["resolvable"] == serde_json::json!(true) {
716 lines.push(format!(
717 " - process `{}`: {} open entries; {} already searched (do not redo)",
718 p["binding"].as_str().unwrap_or("?"),
719 p["open_entries"]["count"].as_u64().unwrap_or(0),
720 p["already_searched"]["count"].as_u64().unwrap_or(0),
721 ));
722 } else {
723 lines.push(format!(
724 " - process `{}`: not resolvable (mem not mounted)",
725 p["binding"].as_str().unwrap_or("?"),
726 ));
727 }
728 }
729 }
730 }
731 lines.push(String::new());
732 }
733
734 if let Some(axis) = checks_axis.as_ref().and_then(|a| a.as_object()) {
739 lines.push(format!("## Checks ({} mems)", axis.len()));
740 for (mem, c) in axis {
741 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
742 let gate = |key: &str| {
743 c.get("independence")
744 .and_then(|g| g.get(key))
745 .and_then(|e| e.get("count"))
746 .and_then(|x| x.as_u64())
747 .unwrap_or(0)
748 };
749 lines.push(format!(
750 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
751 check_stale {}; independence: self_checked {}, \
752 confirmed_independent {}, unconfirmable {}",
753 count("never_checked"),
754 count("checked_ok"),
755 count("check_failed"),
756 count("check_stale"),
757 gate("self_checked"),
758 gate("confirmed_independent"),
759 gate("unconfirmable"),
760 ));
761 }
762 lines.push(String::new());
763 }
764
765 if let Some(axis) = obj.get("signals") {
767 lines.push(format!(
768 "## Signals (notice {}, warn {})",
769 axis["counts"]["notice"].as_u64().unwrap_or(0),
770 axis["counts"]["warn"].as_u64().unwrap_or(0),
771 ));
772 for e in axis["entities"].as_array().into_iter().flatten() {
773 for s in e["signals"].as_array().into_iter().flatten() {
774 let contributors = s["contributors"]
775 .as_array()
776 .map(|a| {
777 a.iter()
778 .filter_map(|c| c.as_str())
779 .collect::<Vec<_>>()
780 .join(", ")
781 })
782 .unwrap_or_default();
783 lines.push(format!(
784 "- {} — {}: {} ({}) [{}]",
785 e["id"].as_str().unwrap_or(""),
786 s["name"].as_str().unwrap_or(""),
787 s["value"].as_u64().unwrap_or(0),
788 s["level"].as_str().unwrap_or(""),
789 contributors,
790 ));
791 }
792 }
793 lines.push(String::new());
794 }
795
796 if let Some(axis) = obj.get("labelling").and_then(|a| a.as_object()) {
798 lines.push(format!("## Labelling ({} mems)", axis.len()));
799 for (mem, m) in axis {
800 let c = &m["counts"];
801 lines.push(format!(
802 "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
803 c["accepted"].as_u64().unwrap_or(0),
804 c["defeated"].as_u64().unwrap_or(0),
805 c["undecided"].as_u64().unwrap_or(0),
806 m["cross_mem_edges_excluded"].as_u64().unwrap_or(0),
807 ));
808 for d in m["defeated"].as_array().into_iter().flatten() {
809 let by = d["defeated_by"]
810 .as_array()
811 .map(|a| {
812 a.iter()
813 .filter_map(|x| x.as_str())
814 .collect::<Vec<_>>()
815 .join(", ")
816 })
817 .unwrap_or_default();
818 lines.push(format!(
819 " - defeated: {} (by {by})",
820 d["id"].as_str().unwrap_or("")
821 ));
822 }
823 for u in m["undecided"].as_array().into_iter().flatten() {
824 let by = u["undecided_by"]
825 .as_array()
826 .map(|a| {
827 a.iter()
828 .filter_map(|x| x.as_str())
829 .collect::<Vec<_>>()
830 .join(", ")
831 })
832 .unwrap_or_default();
833 lines.push(format!(
834 " - undecided: {} (open attackers {by})",
835 u["id"].as_str().unwrap_or("")
836 ));
837 }
838 }
839 lines.push(String::new());
840 }
841
842 if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
845 let total: usize = axis
846 .values()
847 .filter_map(|a| a.as_array().map(|a| a.len()))
848 .sum();
849 lines.push(format!("## Stale derivations ({total} findings)"));
850 for (mem, findings) in axis {
851 for f in findings.as_array().into_iter().flatten() {
852 lines.push(format!(
853 "- `{mem}`: {} -[{}]-> {} ({})",
854 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
855 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
856 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
857 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
858 ));
859 }
860 }
861 lines.push(String::new());
862 }
863
864 if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
869 lines.push(format!("## Quarantined mems ({})", arr.len()));
870 for q in arr {
871 lines.push(format!(
872 "- `{}` [{}] {}",
873 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
874 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
875 q.get("reason_message")
876 .and_then(|x| x.as_str())
877 .unwrap_or(""),
878 ));
879 }
880 lines.push(String::new());
881 }
882
883 if let Some(arr) = obj.get("load_errors").and_then(|v| v.as_array()) {
886 lines.push(format!("## Load errors ({})", arr.len()));
887 for e in arr {
888 lines.push(format!(
889 "- `{}` — {}",
890 e.get("file").and_then(|x| x.as_str()).unwrap_or(""),
891 e.get("error").and_then(|x| x.as_str()).unwrap_or(""),
892 ));
893 }
894 lines.push(String::new());
895 }
896
897 if let Some(f) = &friction_axis {
898 lines.push(format!(
899 "## Friction ({} refusals recorded, {} in the last 24h)",
900 f["total"].as_u64().unwrap_or(0),
901 f["recent_24h"]["total"].as_u64().unwrap_or(0),
902 ));
903 if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
904 lines.push("- by code:".to_string());
905 let mut entries: Vec<(&String, u64)> = by_code
906 .iter()
907 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
908 .collect();
909 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
910 for (code, count) in entries {
911 lines.push(format!(" - {code}: {count}"));
912 if let Some(reasons) = f["by_reason"][code.as_str()]
915 .as_object()
916 .filter(|m| !m.is_empty())
917 {
918 let mut rs: Vec<(&String, u64)> = reasons
919 .iter()
920 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
921 .collect();
922 rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
923 for (reason, count) in rs {
924 lines.push(format!(" - {reason}: {count}"));
925 }
926 }
927 }
928 }
929 if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
930 lines.push("- by verb:".to_string());
931 let mut entries: Vec<(&String, u64)> = by_verb
932 .iter()
933 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
934 .collect();
935 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
936 for (verb, count) in entries {
937 lines.push(format!(" - {verb}: {count}"));
938 }
939 }
940 lines.push(String::new());
941 }
942
943 print_markdown(&lines.join("\n"));
944 strict_exit(args.strict, &strict_violations)
945}
946
947type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
953
954struct GatheredHealth {
958 health: HealthSummary,
959 findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
963 real_count: usize,
964 orphan_ids: Vec<(EntityId, String)>,
967 stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
968 community_count: usize,
969 orphans_by_schema: std::collections::BTreeMap<String, usize>,
974 communities_by_schema: std::collections::BTreeMap<String, usize>,
975 most_connected_with_titles: Vec<MostConnectedRow>,
977 missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
978 constraint_findings: Vec<ConstraintFindingReport>,
981 schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
984 tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
994 dangling_links: Vec<DanglingLink>,
998 config_entries: Option<serde_json::Map<String, serde_json::Value>>,
1005 anchors_axis: Option<serde_json::Value>,
1010 open_questions_axis: Option<serde_json::Value>,
1014 stale_derivations_axis: Option<serde_json::Value>,
1018 checks_axis: Option<serde_json::Value>,
1022 signals_axis: Option<serde_json::Value>,
1025 labelling_axis: Option<serde_json::Value>,
1029}
1030
1031fn gather_findings(
1037 engine: &memstead_base::Engine,
1038 include: &[String],
1039 target_schema: Option<&str>,
1040) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
1041 let wants_conformance = include
1042 .iter()
1043 .any(|s| s == "conformance" || s == "integrity");
1044 if !wants_conformance {
1045 return Ok(Vec::new());
1046 }
1047 let target: Option<memstead_schema::SchemaRef> = match target_schema {
1048 None => None,
1049 Some(raw) => Some(
1050 raw.parse::<memstead_schema::SchemaRef>()
1051 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
1052 ),
1053 };
1054 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
1055 mems.sort();
1056 let mut findings = Vec::new();
1057 for v in &mems {
1058 findings.extend(
1059 engine
1060 .conformance_findings(v, target.as_ref())
1061 .map_err(crate::CliError::from_engine_op)?,
1062 );
1063 if include.iter().any(|s| s == "integrity") {
1064 findings.extend(
1065 engine
1066 .consistency_findings(v)
1067 .map_err(crate::CliError::from_engine_op)?,
1068 );
1069 }
1070 }
1071 Ok(findings)
1072}
1073
1074#[cfg(feature = "mem-repo")]
1075fn gather_mem_repo(
1076 engine: &mut memstead_base::Engine,
1077 limit: usize,
1078 include: &[String],
1079) -> GatheredHealth {
1080 let mut g = gather_from_store(
1081 engine.health(),
1082 engine.store(),
1083 engine.communities().count,
1084 limit,
1085 include,
1086 || engine.orphans(),
1087 |limit| engine_most_connected_mem_repo(engine, limit),
1088 || engine.missing_required_outgoing(None),
1089 || engine.constraint_findings(None),
1090 || engine.schema_format_defects(),
1091 );
1092 fill_schema_breakdowns(engine, &mut g);
1093 fill_config_projection(engine, include, &mut g);
1094 fill_anchors_axis(engine, include, &mut g);
1095 fill_open_questions_axis(engine, include, &mut g);
1096 fill_stale_derivations_axis(engine, include, &mut g);
1097 fill_checks_axis(engine, include, &mut g);
1098 fill_signals_axis(engine, include, &mut g);
1099 fill_labelling_axis(engine, include, &mut g);
1100 g
1101}
1102
1103fn gather_filesystem(
1104 engine: &mut memstead_base::Engine,
1105 limit: usize,
1106 include: &[String],
1107) -> GatheredHealth {
1108 let mut g = gather_from_store(
1109 engine.health(),
1110 engine.store(),
1111 engine.communities().count,
1112 limit,
1113 include,
1114 || engine.orphans(),
1115 |limit| engine_most_connected_filesystem(engine, limit),
1116 || engine.missing_required_outgoing(None),
1117 || engine.constraint_findings(None),
1118 || engine.schema_format_defects(),
1119 );
1120 fill_schema_breakdowns(engine, &mut g);
1121 fill_config_projection(engine, include, &mut g);
1122 fill_anchors_axis(engine, include, &mut g);
1123 fill_open_questions_axis(engine, include, &mut g);
1124 fill_stale_derivations_axis(engine, include, &mut g);
1125 fill_checks_axis(engine, include, &mut g);
1126 fill_signals_axis(engine, include, &mut g);
1127 fill_labelling_axis(engine, include, &mut g);
1128 g
1129}
1130
1131fn fill_config_projection(
1137 engine: &memstead_base::Engine,
1138 include: &[String],
1139 g: &mut GatheredHealth,
1140) {
1141 if include.iter().any(|s| s == "config") {
1142 let mut mems: Vec<String> = engine
1143 .mem_router()
1144 .writable_mems()
1145 .iter()
1146 .cloned()
1147 .collect();
1148 mems.sort();
1149 let (mutations, plugin) =
1150 memstead_base::ops::health::config_projection_from_settings(engine.settings());
1151 g.config_entries = Some(memstead_base::ops::health::config_projection(
1152 engine, &mems, mutations, plugin,
1153 ));
1154 }
1155}
1156
1157fn fill_open_questions_axis(
1163 engine: &memstead_base::Engine,
1164 include: &[String],
1165 g: &mut GatheredHealth,
1166) {
1167 if include.iter().any(|s| s == "open_questions") {
1168 g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
1169 engine, None,
1170 ));
1171 }
1172}
1173
1174fn fill_stale_derivations_axis(
1178 engine: &memstead_base::Engine,
1179 include: &[String],
1180 g: &mut GatheredHealth,
1181) {
1182 if include.iter().any(|s| s == "stale_derivations") {
1183 g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
1184 engine, None,
1185 ));
1186 }
1187}
1188
1189fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1190 if include.iter().any(|s| s == "checks") {
1191 g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1192 }
1193}
1194
1195fn fill_signals_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1196 if include.iter().any(|s| s == "signals") {
1197 g.signals_axis = Some(engine.health_signals_axis(None));
1198 }
1199}
1200
1201fn fill_labelling_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1202 if include.iter().any(|s| s == "labelling") {
1203 g.labelling_axis = Some(engine.health_labelling_axis(None));
1204 }
1205}
1206
1207fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1208 if include.iter().any(|s| s == "anchors") {
1209 g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1210 }
1211}
1212
1213fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1214 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1215 g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1216 g.communities_by_schema = engine.communities_by_schema(&mems);
1217}
1218
1219#[allow(clippy::too_many_arguments)]
1227fn gather_from_store(
1228 health: HealthSummary,
1229 store: &Store,
1230 community_count: usize,
1231 limit: usize,
1232 include: &[String],
1233 orphans_fn: impl FnOnce() -> Vec<EntityId>,
1234 most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1235 missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1236 constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1237 schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1238) -> GatheredHealth {
1239 let real_count = store.all_entities().filter(|e| !e.stub).count();
1240 let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1241 .into_iter()
1242 .map(|id| {
1243 let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1244 (id, title)
1245 })
1246 .collect();
1247 let stub_pairs = memstead_base::graph::query::find_stubs(store);
1248 let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1249 most_connected_fn(limit)
1250 } else {
1251 Vec::new()
1252 };
1253 let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1254 missing_required_outgoing_fn()
1255 } else {
1256 Vec::new()
1257 };
1258 let constraint_findings = if include.iter().any(|s| s == "constraints") {
1259 constraint_findings_fn()
1260 } else {
1261 Vec::new()
1262 };
1263 let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1264 schema_format_defects_fn()
1265 } else {
1266 Vec::new()
1267 };
1268 let tag_distribution = if include.iter().any(|s| s == "tags") {
1269 let (distribution, folded, untagged) =
1270 memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1271 Some((
1272 serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1273 serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1274 serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1275 ))
1276 } else {
1277 None
1278 };
1279 let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1280 memstead_base::ops::health::collect_dangling_links(store, None)
1281 } else {
1282 Vec::new()
1283 };
1284 GatheredHealth {
1285 health,
1286 findings: Vec::new(),
1287 real_count,
1288 orphan_ids,
1289 stub_pairs,
1290 community_count,
1291 orphans_by_schema: std::collections::BTreeMap::new(),
1294 communities_by_schema: std::collections::BTreeMap::new(),
1295 most_connected_with_titles,
1296 missing_required_outgoing,
1297 constraint_findings,
1298 schema_format_defects,
1299 tag_distribution,
1300 dangling_links,
1301 config_entries: None,
1302 anchors_axis: None,
1303 open_questions_axis: None,
1304 stale_derivations_axis: None,
1305 checks_axis: None,
1306 signals_axis: None,
1307 labelling_axis: None,
1308 }
1309}
1310
1311#[cfg(feature = "mem-repo")]
1312fn engine_most_connected_mem_repo(
1313 engine: &memstead_base::Engine,
1314 limit: usize,
1315) -> Vec<MostConnectedRow> {
1316 engine
1317 .most_connected(limit)
1318 .into_iter()
1319 .map(|c| {
1320 let title = engine
1321 .get_entity(&c.id)
1322 .map(|e| e.title.clone())
1323 .unwrap_or_default();
1324 (
1325 c.id,
1326 title,
1327 c.total,
1328 c.incoming,
1329 c.outgoing,
1330 c.typed_total,
1331 c.typed_incoming,
1332 c.typed_outgoing,
1333 )
1334 })
1335 .collect()
1336}
1337
1338fn engine_most_connected_filesystem(
1339 engine: &memstead_base::Engine,
1340 limit: usize,
1341) -> Vec<MostConnectedRow> {
1342 engine
1343 .most_connected(limit)
1344 .into_iter()
1345 .map(|c| {
1346 let title = engine
1347 .get_entity(&c.id)
1348 .map(|e| e.title.clone())
1349 .unwrap_or_default();
1350 (
1351 c.id,
1352 title,
1353 c.total,
1354 c.incoming,
1355 c.outgoing,
1356 c.typed_total,
1357 c.typed_incoming,
1358 c.typed_outgoing,
1359 )
1360 })
1361 .collect()
1362}
1363
1364fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1370 if !strict || violations.is_empty() {
1371 return Ok(());
1372 }
1373 let summary = violations
1374 .iter()
1375 .map(|(code, n)| format!("{code}: {n}"))
1376 .collect::<Vec<_>>()
1377 .join(", ");
1378 Err(crate::CliError::new(
1379 ExitKind::Generic,
1380 "HEALTH_STRICT_VIOLATIONS",
1381 format!("strict mode: tier-2 violations present ({summary})"),
1382 )
1383 .into())
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388 use super::*;
1389 use clap::CommandFactory;
1390
1391 #[test]
1392 fn help_lists_every_include_key() {
1393 let cmd = Args::command();
1394 let arg = cmd
1395 .get_arguments()
1396 .find(|a| a.get_id() == "include")
1397 .expect("--include arg must exist");
1398 let help = arg
1399 .get_help()
1400 .expect("--include must have help text")
1401 .to_string();
1402 for key in HEALTH_INCLUDE_KEYS {
1403 assert!(
1404 help.contains(key),
1405 "`memstead health --help` must name include key `{key}` (got: {help})"
1406 );
1407 }
1408 }
1409}