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 = ',')]
54 pub include: Vec<String>,
55
56 #[arg(long)]
59 pub target_schema: Option<String>,
60
61 #[arg(long, default_value_t = 10)]
63 pub limit: usize,
64
65 #[arg(long)]
74 pub strict: bool,
75}
76
77pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
78 let include = &args.include;
79 let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
85
86 let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
91 for key in include {
92 if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
93 include_warnings.push((
94 key.clone(),
95 HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
96 ));
97 }
98 }
99
100 let GatheredHealth {
101 health,
102 real_count,
103 orphan_ids,
104 stub_pairs,
105 community_count,
106 orphans_by_schema,
107 communities_by_schema,
108 most_connected_with_titles,
109 missing_required_outgoing,
110 constraint_findings,
111 schema_format_defects,
112 tag_distribution,
113 dangling_links,
114 findings,
115 config_entries,
116 anchors_axis,
117 open_questions_axis,
118 stale_derivations_axis,
119 checks_axis,
120 } = match ctx.cli_engine()? {
121 #[cfg(feature = "mem-repo")]
122 CliEngine::MemRepo(mut engine) => {
123 let mut g = gather_mem_repo(&mut engine, args.limit, include);
124 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
125 g
126 }
127 CliEngine::Filesystem(mut engine) => {
128 let mut g = gather_filesystem(&mut engine, args.limit, include);
129 g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
130 g
131 }
132 };
133
134 let mut result = json!({
135 "summary": {
136 "total_entities": real_count,
137 "total_orphans": orphan_ids.len(),
138 "total_stubs": stub_pairs.len(),
139 "total_stale": health.stale_entities.len(),
140 "total_missing_fields": health.missing_fields.len(),
141 "total_communities": community_count,
142 "orphans_by_schema": orphans_by_schema,
143 "communities_by_schema": communities_by_schema,
144 },
145 });
146 let obj = result.as_object_mut().unwrap();
147
148 if include.iter().any(|s| s == "orphans") {
149 let list: Vec<_> = orphan_ids
150 .iter()
151 .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
152 .collect();
153 obj.insert("orphans".into(), json!(list));
154 }
155 if include.iter().any(|s| s == "stubs") {
156 let list: Vec<_> = stub_pairs
157 .iter()
158 .map(|(id, refs)| {
159 json!({
160 "id": id.to_string(),
161 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
162 })
163 })
164 .collect();
165 obj.insert("stubs".into(), json!(list));
166 }
167 if include.iter().any(|s| s == "most_connected") {
168 let connected: Vec<_> = most_connected_with_titles
169 .iter()
170 .map(
171 |(
172 id,
173 title,
174 total,
175 incoming,
176 outgoing,
177 typed_total,
178 typed_incoming,
179 typed_outgoing,
180 )| {
181 json!({
182 "id": id.to_string(),
183 "title": title,
184 "total": total,
185 "incoming": incoming,
186 "outgoing": outgoing,
187 "typed_total": typed_total,
188 "typed_incoming": typed_incoming,
189 "typed_outgoing": typed_outgoing,
190 })
191 },
192 )
193 .collect();
194 obj.insert("most_connected".into(), json!(connected));
195 }
196 if include.iter().any(|s| s == "missing_fields") {
197 let list: Vec<_> = health
198 .missing_fields
199 .iter()
200 .map(|h| {
201 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
207 let issues: Vec<_> = h
208 .issues
209 .iter()
210 .map(|i| json!({ "field": i.field, "code": i.code, "message": i.message }))
211 .collect();
212 json!({
213 "id": h.id.to_string(),
214 "title": h.title,
215 "missing": missing,
216 "issues": issues,
217 })
218 })
219 .collect();
220 obj.insert("missing_fields".into(), json!(list));
221 }
222 if include.iter().any(|s| s == "stale") {
223 let list: Vec<_> = health
224 .stale_entities
225 .iter()
226 .map(|e| {
227 json!({
228 "id": e.id.to_string(),
229 "title": e.title,
230 "days_since_modified": e.days_since_modified,
231 })
232 })
233 .collect();
234 obj.insert("stale".into(), json!(list));
235 }
236 if include.iter().any(|s| s == "missing_required_outgoing") {
237 if !missing_required_outgoing.is_empty() {
238 strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
239 }
240 obj.insert(
241 "missing_required_outgoing".into(),
242 serde_json::to_value(&missing_required_outgoing)?,
243 );
244 }
245 if include.iter().any(|s| s == "constraints") {
246 if !constraint_findings.is_empty() {
247 strict_violations.push(("constraints", constraint_findings.len()));
248 }
249 obj.insert(
250 "constraints".into(),
251 serde_json::to_value(&constraint_findings)?,
252 );
253 if !schema_format_defects.is_empty() {
256 strict_violations.push(("schema_format_defects", schema_format_defects.len()));
257 obj.insert(
258 "schema_format_defects".into(),
259 serde_json::to_value(&schema_format_defects)?,
260 );
261 }
262 }
263 if include.iter().any(|s| s == "dangling_links") {
264 let arr: Vec<serde_json::Value> = dangling_links
265 .iter()
266 .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
267 .collect();
268 obj.insert("dangling_links".into(), json!(arr));
269 }
270 if include
271 .iter()
272 .any(|s| s == "conformance" || s == "integrity")
273 {
274 obj.insert("findings".into(), serde_json::to_value(&findings)?);
275 }
276 if include.iter().any(|s| s == "tags")
277 && let Some((distribution, folded, untagged)) = tag_distribution
278 {
279 obj.insert("tag_distribution".into(), distribution);
280 obj.insert("tag_distribution_folded".into(), folded);
281 obj.insert("untagged_entities".into(), untagged);
282 }
283 if let Some(entries) = config_entries {
287 for (k, v) in entries {
288 obj.insert(k, v);
289 }
290 }
291 if let Some(axis) = &anchors_axis {
292 obj.insert("anchors".to_string(), axis.clone());
293 }
294 if let Some(axis) = &open_questions_axis {
295 obj.insert("open_questions".to_string(), axis.clone());
296 }
297 if let Some(axis) = &stale_derivations_axis {
298 obj.insert("stale_derivations".to_string(), axis.clone());
299 }
300 if let Some(axis) = &checks_axis {
301 obj.insert("checks".to_string(), axis.clone());
302 }
303 let friction_axis = if include.iter().any(|s| s == "friction") {
308 let summary = std::env::current_dir()
309 .ok()
310 .and_then(|cwd| crate::setup::find_workspace_root(&cwd))
311 .map(|root| memstead_base::friction::FrictionLedger::for_workspace(&root).summarize())
312 .unwrap_or_else(|| {
313 json!({
314 "total": 0,
315 "by_code": {},
316 "by_verb": {},
317 "recent_24h": { "total": 0, "by_code": {} },
318 "ledger_bytes": 0,
319 })
320 });
321 obj.insert("friction".to_string(), summary.clone());
322 Some(summary)
323 } else {
324 None
325 };
326
327 let mut warning_payload: Vec<serde_json::Value> = health
335 .warnings
336 .iter()
337 .filter_map(|w| serde_json::to_value(w).ok())
338 .collect();
339 warning_payload.extend(include_warnings.iter().map(|(key, allowed)| {
340 json!({
341 "code": "UNKNOWN_INCLUDE_KEY",
342 "message": format!(
343 "unknown include key: \"{key}\". Allowed: {}",
344 allowed.join(", ")
345 ),
346 "details": { "key": key, "allowed": allowed },
347 })
348 }));
349 if !warning_payload.is_empty() {
350 obj.insert("warnings".into(), json!(warning_payload));
351 }
352 if !health.leaf_entities_by_type.is_empty() {
355 obj.insert(
356 "leaf_entities_by_type".into(),
357 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
358 );
359 }
360 if !health.quarantined.is_empty() {
363 obj.insert(
364 "quarantined".into(),
365 serde_json::to_value(&health.quarantined).unwrap_or_default(),
366 );
367 }
368 if let Some(diag) = &health.boot_diagnosis {
369 obj.insert("boot_diagnosis".into(), diag.clone());
370 }
371
372 let authoring_drift = health
378 .warnings
379 .iter()
380 .filter(|w| {
381 matches!(
382 w.code(),
383 "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
384 )
385 })
386 .count();
387 if authoring_drift > 0 {
388 strict_violations.push(("schema_authoring_drift", authoring_drift));
389 }
390
391 if ctx.json {
392 print_json(&result)?;
393 return strict_exit(args.strict, &strict_violations);
394 }
395
396 let mut lines = Vec::new();
398 lines.push("# Graph health".to_string());
399 lines.push(String::new());
400 lines.push(format!("- Entities: {real_count}"));
401 if orphans_by_schema.len() > 1 {
402 let by: Vec<String> = orphans_by_schema
405 .iter()
406 .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
407 .collect();
408 lines.push(format!(
409 "- Orphans: {} ({})",
410 orphan_ids.len(),
411 by.join(", ")
412 ));
413 } else {
414 lines.push(format!("- Orphans: {}", orphan_ids.len()));
415 }
416 lines.push(format!("- Stubs: {}", stub_pairs.len()));
417 lines.push(format!("- Stale: {}", health.stale_entities.len()));
418 lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
419 lines.push(format!("- Communities: {community_count}"));
420 lines.push(String::new());
421
422 if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
423 lines.push("## Orphans".to_string());
424 for item in v {
425 lines.push(format!(
426 "- {} — {}",
427 item["id"].as_str().unwrap_or(""),
428 item["title"].as_str().unwrap_or("")
429 ));
430 }
431 lines.push(String::new());
432 }
433 if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
434 lines.push("## Stubs".to_string());
435 for item in v {
436 lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
437 }
438 lines.push(String::new());
439 }
440 if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
441 lines.push("## Most connected".to_string());
442 lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
443 for item in v {
444 lines.push(format!(
445 "- {} — {} (typed {}, total {}, in {}, out {})",
446 item["id"].as_str().unwrap_or(""),
447 item["title"].as_str().unwrap_or(""),
448 item["typed_total"].as_u64().unwrap_or(0),
449 item["total"].as_u64().unwrap_or(0),
450 item["incoming"].as_u64().unwrap_or(0),
451 item["outgoing"].as_u64().unwrap_or(0),
452 ));
453 }
454 lines.push(String::new());
455 }
456 if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
457 lines.push("## Missing fields".to_string());
458 for item in v {
459 let labels: Vec<String> = match item["issues"].as_array() {
465 Some(issues) if !issues.is_empty() => issues
466 .iter()
467 .map(|i| {
468 format!(
469 "{} ({})",
470 i["field"].as_str().unwrap_or(""),
471 i["code"].as_str().unwrap_or("MISSING"),
472 )
473 })
474 .collect(),
475 _ => item["missing"]
476 .as_array()
477 .map(|a| {
478 a.iter()
479 .filter_map(|s| s.as_str())
480 .map(str::to_string)
481 .collect()
482 })
483 .unwrap_or_default(),
484 };
485 lines.push(format!(
486 "- {} — {} (issues: {})",
487 item["id"].as_str().unwrap_or(""),
488 item["title"].as_str().unwrap_or(""),
489 labels.join(", ")
490 ));
491 }
492 lines.push(String::new());
493 }
494 if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
495 lines.push("## Stale entities".to_string());
496 for item in v {
497 lines.push(format!(
498 "- {} — {} ({} days)",
499 item["id"].as_str().unwrap_or(""),
500 item["title"].as_str().unwrap_or(""),
501 item["days_since_modified"].as_u64().unwrap_or(0)
502 ));
503 }
504 lines.push(String::new());
505 }
506 if let Some(v) = obj
507 .get("missing_required_outgoing")
508 .and_then(|v| v.as_array())
509 {
510 lines.push("## Missing required outgoing".to_string());
511 for item in v {
512 let blocks: Vec<String> = item["missing"]
513 .as_array()
514 .map(|arr| {
515 arr.iter()
516 .map(|b| {
517 let rels: Vec<&str> = b["relationships"]
518 .as_array()
519 .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
520 .unwrap_or_default();
521 format!(
522 "[{}] {}",
523 rels.join(", "),
524 b["cardinality"].as_str().unwrap_or("")
525 )
526 })
527 .collect()
528 })
529 .unwrap_or_default();
530 lines.push(format!(
531 "- {} — {} (missing: {})",
532 item["id"].as_str().unwrap_or(""),
533 item["title"].as_str().unwrap_or(""),
534 blocks.join("; ")
535 ));
536 }
537 lines.push(String::new());
538 }
539 if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
540 lines.push("## Dangling links".to_string());
541 for item in v {
542 lines.push(format!(
543 "- {} → {} (section: {})",
544 item["from"].as_str().unwrap_or(""),
545 item["target_id"].as_str().unwrap_or(""),
546 item["section"].as_str().unwrap_or("(none)")
547 ));
548 }
549 lines.push(String::new());
550 }
551 if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
552 lines.push("## Tags".to_string());
553 for item in v {
554 lines.push(format!(
555 "- {} ({})",
556 item["tag"].as_str().unwrap_or(""),
557 item["count"].as_u64().unwrap_or(0)
558 ));
559 }
560 lines.push(String::new());
561 }
562 if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
563 lines.push("## Warnings".to_string());
564 for w in v {
565 lines.push(format!(
566 "- {} — {}",
567 w["code"].as_str().unwrap_or(""),
568 w["message"].as_str().unwrap_or("")
569 ));
570 }
571 lines.push(String::new());
572 }
573 if let Some(u) = obj.get("untagged_entities") {
574 lines.push("## Untagged".to_string());
575 lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
576 if let Some(by_type) = u["by_entity_type"].as_object() {
577 let mut entries: Vec<(&String, u64)> = by_type
578 .iter()
579 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
580 .collect();
581 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
582 for (kind, count) in entries {
583 lines.push(format!(" - {kind}: {count}"));
584 }
585 }
586 lines.push(String::new());
587 }
588
589 if let Some(axis) = anchors_axis.as_ref().and_then(|a| a.as_object()) {
590 lines.push(format!("## Anchors ({} mems)", axis.len()));
591 for (mem, counts) in axis {
592 lines.push(format!(
593 "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable {}",
594 counts["resolved"].as_u64().unwrap_or(0),
595 counts["drifted"].as_u64().unwrap_or(0),
596 counts["recheck"].as_u64().unwrap_or(0),
597 counts["unresolvable"].as_u64().unwrap_or(0),
598 ));
599 }
600 lines.push(String::new());
601 }
602
603 if let Some(axis) = open_questions_axis.as_ref().and_then(|a| a.as_object()) {
604 let cap = axis
605 .get("_item_cap")
606 .and_then(|v| v.as_u64())
607 .unwrap_or_default();
608 lines.push(format!("## Open questions (item cap {cap} per kind)"));
609 for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
610 let total = entry["total_open"].as_u64().unwrap_or(0);
611 lines.push(format!("- `{mem}`: {total} open"));
612 for kind in [
613 "stubs",
614 "anchors_recheck",
615 "anchors_unresolvable",
616 "unsatisfied_constraints",
617 "dangling_links",
618 ] {
619 let count = entry[kind]["count"].as_u64().unwrap_or(0);
620 if count > 0 {
621 let more = entry[kind]["more"].as_u64().unwrap_or(0);
622 let suffix = if more > 0 {
623 format!(" ({more} more not shown)")
624 } else {
625 String::new()
626 };
627 lines.push(format!(" - {kind}: {count}{suffix}"));
628 }
629 }
630 if let Some(process) = entry.get("process").and_then(|p| p.as_array()) {
631 for p in process {
632 if p["resolvable"] == serde_json::json!(true) {
633 lines.push(format!(
634 " - process `{}`: {} open entries; {} already searched (do not redo)",
635 p["binding"].as_str().unwrap_or("?"),
636 p["open_entries"]["count"].as_u64().unwrap_or(0),
637 p["already_searched"]["count"].as_u64().unwrap_or(0),
638 ));
639 } else {
640 lines.push(format!(
641 " - process `{}`: not resolvable (mem not mounted)",
642 p["binding"].as_str().unwrap_or("?"),
643 ));
644 }
645 }
646 }
647 }
648 lines.push(String::new());
649 }
650
651 if let Some(axis) = checks_axis.as_ref().and_then(|a| a.as_object()) {
656 lines.push(format!("## Checks ({} mems)", axis.len()));
657 for (mem, c) in axis {
658 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
659 let gate = |key: &str| {
660 c.get("independence")
661 .and_then(|g| g.get(key))
662 .and_then(|e| e.get("count"))
663 .and_then(|x| x.as_u64())
664 .unwrap_or(0)
665 };
666 lines.push(format!(
667 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
668 check_stale {}; independence: self_checked {}, \
669 confirmed_independent {}, unconfirmable {}",
670 count("never_checked"),
671 count("checked_ok"),
672 count("check_failed"),
673 count("check_stale"),
674 gate("self_checked"),
675 gate("confirmed_independent"),
676 gate("unconfirmable"),
677 ));
678 }
679 lines.push(String::new());
680 }
681
682 if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
685 let total: usize = axis
686 .values()
687 .filter_map(|a| a.as_array().map(|a| a.len()))
688 .sum();
689 lines.push(format!("## Stale derivations ({total} findings)"));
690 for (mem, findings) in axis {
691 for f in findings.as_array().into_iter().flatten() {
692 lines.push(format!(
693 "- `{mem}`: {} -[{}]-> {} ({})",
694 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
695 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
696 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
697 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
698 ));
699 }
700 }
701 lines.push(String::new());
702 }
703
704 if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
709 lines.push(format!("## Quarantined mems ({})", arr.len()));
710 for q in arr {
711 lines.push(format!(
712 "- `{}` [{}] {}",
713 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
714 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
715 q.get("reason_message")
716 .and_then(|x| x.as_str())
717 .unwrap_or(""),
718 ));
719 }
720 lines.push(String::new());
721 }
722
723 if let Some(f) = &friction_axis {
724 lines.push(format!(
725 "## Friction ({} refusals recorded, {} in the last 24h)",
726 f["total"].as_u64().unwrap_or(0),
727 f["recent_24h"]["total"].as_u64().unwrap_or(0),
728 ));
729 if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
730 lines.push("- by code:".to_string());
731 let mut entries: Vec<(&String, u64)> = by_code
732 .iter()
733 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
734 .collect();
735 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
736 for (code, count) in entries {
737 lines.push(format!(" - {code}: {count}"));
738 if let Some(reasons) = f["by_reason"][code.as_str()]
741 .as_object()
742 .filter(|m| !m.is_empty())
743 {
744 let mut rs: Vec<(&String, u64)> = reasons
745 .iter()
746 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
747 .collect();
748 rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
749 for (reason, count) in rs {
750 lines.push(format!(" - {reason}: {count}"));
751 }
752 }
753 }
754 }
755 if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
756 lines.push("- by verb:".to_string());
757 let mut entries: Vec<(&String, u64)> = by_verb
758 .iter()
759 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
760 .collect();
761 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
762 for (verb, count) in entries {
763 lines.push(format!(" - {verb}: {count}"));
764 }
765 }
766 lines.push(String::new());
767 }
768
769 print_markdown(&lines.join("\n"));
770 strict_exit(args.strict, &strict_violations)
771}
772
773type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
779
780struct GatheredHealth {
784 health: HealthSummary,
785 findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
789 real_count: usize,
790 orphan_ids: Vec<(EntityId, String)>,
793 stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
794 community_count: usize,
795 orphans_by_schema: std::collections::BTreeMap<String, usize>,
800 communities_by_schema: std::collections::BTreeMap<String, usize>,
801 most_connected_with_titles: Vec<MostConnectedRow>,
803 missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
804 constraint_findings: Vec<ConstraintFindingReport>,
807 schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
810 tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
820 dangling_links: Vec<DanglingLink>,
824 config_entries: Option<serde_json::Map<String, serde_json::Value>>,
831 anchors_axis: Option<serde_json::Value>,
836 open_questions_axis: Option<serde_json::Value>,
840 stale_derivations_axis: Option<serde_json::Value>,
844 checks_axis: Option<serde_json::Value>,
848}
849
850fn gather_findings(
856 engine: &memstead_base::Engine,
857 include: &[String],
858 target_schema: Option<&str>,
859) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
860 let wants_conformance = include
861 .iter()
862 .any(|s| s == "conformance" || s == "integrity");
863 if !wants_conformance {
864 return Ok(Vec::new());
865 }
866 let target: Option<memstead_schema::SchemaRef> = match target_schema {
867 None => None,
868 Some(raw) => Some(
869 raw.parse::<memstead_schema::SchemaRef>()
870 .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
871 ),
872 };
873 let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
874 mems.sort();
875 let mut findings = Vec::new();
876 for v in &mems {
877 findings.extend(
878 engine
879 .conformance_findings(v, target.as_ref())
880 .map_err(crate::CliError::from_engine_op)?,
881 );
882 if include.iter().any(|s| s == "integrity") {
883 findings.extend(
884 engine
885 .consistency_findings(v)
886 .map_err(crate::CliError::from_engine_op)?,
887 );
888 }
889 }
890 Ok(findings)
891}
892
893#[cfg(feature = "mem-repo")]
894fn gather_mem_repo(
895 engine: &mut memstead_base::Engine,
896 limit: usize,
897 include: &[String],
898) -> GatheredHealth {
899 let mut g = gather_from_store(
900 engine.health(),
901 engine.store(),
902 engine.communities().count,
903 limit,
904 include,
905 || engine.orphans(),
906 |limit| engine_most_connected_mem_repo(engine, limit),
907 || engine.missing_required_outgoing(None),
908 || engine.constraint_findings(None),
909 || engine.schema_format_defects(),
910 );
911 fill_schema_breakdowns(engine, &mut g);
912 fill_config_projection(engine, include, &mut g);
913 fill_anchors_axis(engine, include, &mut g);
914 fill_open_questions_axis(engine, include, &mut g);
915 fill_stale_derivations_axis(engine, include, &mut g);
916 fill_checks_axis(engine, include, &mut g);
917 g
918}
919
920fn gather_filesystem(
921 engine: &mut memstead_base::Engine,
922 limit: usize,
923 include: &[String],
924) -> GatheredHealth {
925 let mut g = gather_from_store(
926 engine.health(),
927 engine.store(),
928 engine.communities().count,
929 limit,
930 include,
931 || engine.orphans(),
932 |limit| engine_most_connected_filesystem(engine, limit),
933 || engine.missing_required_outgoing(None),
934 || engine.constraint_findings(None),
935 || engine.schema_format_defects(),
936 );
937 fill_schema_breakdowns(engine, &mut g);
938 fill_config_projection(engine, include, &mut g);
939 fill_anchors_axis(engine, include, &mut g);
940 fill_open_questions_axis(engine, include, &mut g);
941 fill_stale_derivations_axis(engine, include, &mut g);
942 fill_checks_axis(engine, include, &mut g);
943 g
944}
945
946fn fill_config_projection(
952 engine: &memstead_base::Engine,
953 include: &[String],
954 g: &mut GatheredHealth,
955) {
956 if include.iter().any(|s| s == "config") {
957 let mut mems: Vec<String> = engine
958 .mem_router()
959 .writable_mems()
960 .iter()
961 .cloned()
962 .collect();
963 mems.sort();
964 let (mutations, plugin) =
965 memstead_base::ops::health::config_projection_from_settings(engine.settings());
966 g.config_entries = Some(memstead_base::ops::health::config_projection(
967 engine, &mems, mutations, plugin,
968 ));
969 }
970}
971
972fn fill_open_questions_axis(
978 engine: &memstead_base::Engine,
979 include: &[String],
980 g: &mut GatheredHealth,
981) {
982 if include.iter().any(|s| s == "open_questions") {
983 g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
984 engine, None,
985 ));
986 }
987}
988
989fn fill_stale_derivations_axis(
993 engine: &memstead_base::Engine,
994 include: &[String],
995 g: &mut GatheredHealth,
996) {
997 if include.iter().any(|s| s == "stale_derivations") {
998 g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
999 engine, None,
1000 ));
1001 }
1002}
1003
1004fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1005 if include.iter().any(|s| s == "checks") {
1006 g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1007 }
1008}
1009
1010fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1011 if include.iter().any(|s| s == "anchors") {
1012 g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1013 }
1014}
1015
1016fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1017 let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1018 g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1019 g.communities_by_schema = engine.communities_by_schema(&mems);
1020}
1021
1022#[allow(clippy::too_many_arguments)]
1030fn gather_from_store(
1031 health: HealthSummary,
1032 store: &Store,
1033 community_count: usize,
1034 limit: usize,
1035 include: &[String],
1036 orphans_fn: impl FnOnce() -> Vec<EntityId>,
1037 most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1038 missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1039 constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1040 schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1041) -> GatheredHealth {
1042 let real_count = store.all_entities().filter(|e| !e.stub).count();
1043 let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1044 .into_iter()
1045 .map(|id| {
1046 let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1047 (id, title)
1048 })
1049 .collect();
1050 let stub_pairs = memstead_base::graph::query::find_stubs(store);
1051 let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1052 most_connected_fn(limit)
1053 } else {
1054 Vec::new()
1055 };
1056 let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1057 missing_required_outgoing_fn()
1058 } else {
1059 Vec::new()
1060 };
1061 let constraint_findings = if include.iter().any(|s| s == "constraints") {
1062 constraint_findings_fn()
1063 } else {
1064 Vec::new()
1065 };
1066 let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1067 schema_format_defects_fn()
1068 } else {
1069 Vec::new()
1070 };
1071 let tag_distribution = if include.iter().any(|s| s == "tags") {
1072 let (distribution, folded, untagged) =
1073 memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1074 Some((
1075 serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1076 serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1077 serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1078 ))
1079 } else {
1080 None
1081 };
1082 let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1083 memstead_base::ops::health::collect_dangling_links(store, None)
1084 } else {
1085 Vec::new()
1086 };
1087 GatheredHealth {
1088 health,
1089 findings: Vec::new(),
1090 real_count,
1091 orphan_ids,
1092 stub_pairs,
1093 community_count,
1094 orphans_by_schema: std::collections::BTreeMap::new(),
1097 communities_by_schema: std::collections::BTreeMap::new(),
1098 most_connected_with_titles,
1099 missing_required_outgoing,
1100 constraint_findings,
1101 schema_format_defects,
1102 tag_distribution,
1103 dangling_links,
1104 config_entries: None,
1105 anchors_axis: None,
1106 open_questions_axis: None,
1107 stale_derivations_axis: None,
1108 checks_axis: None,
1109 }
1110}
1111
1112#[cfg(feature = "mem-repo")]
1113fn engine_most_connected_mem_repo(
1114 engine: &memstead_base::Engine,
1115 limit: usize,
1116) -> Vec<MostConnectedRow> {
1117 engine
1118 .most_connected(limit)
1119 .into_iter()
1120 .map(|c| {
1121 let title = engine
1122 .get_entity(&c.id)
1123 .map(|e| e.title.clone())
1124 .unwrap_or_default();
1125 (
1126 c.id,
1127 title,
1128 c.total,
1129 c.incoming,
1130 c.outgoing,
1131 c.typed_total,
1132 c.typed_incoming,
1133 c.typed_outgoing,
1134 )
1135 })
1136 .collect()
1137}
1138
1139fn engine_most_connected_filesystem(
1140 engine: &memstead_base::Engine,
1141 limit: usize,
1142) -> Vec<MostConnectedRow> {
1143 engine
1144 .most_connected(limit)
1145 .into_iter()
1146 .map(|c| {
1147 let title = engine
1148 .get_entity(&c.id)
1149 .map(|e| e.title.clone())
1150 .unwrap_or_default();
1151 (
1152 c.id,
1153 title,
1154 c.total,
1155 c.incoming,
1156 c.outgoing,
1157 c.typed_total,
1158 c.typed_incoming,
1159 c.typed_outgoing,
1160 )
1161 })
1162 .collect()
1163}
1164
1165fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1171 if !strict || violations.is_empty() {
1172 return Ok(());
1173 }
1174 let summary = violations
1175 .iter()
1176 .map(|(code, n)| format!("{code}: {n}"))
1177 .collect::<Vec<_>>()
1178 .join(", ");
1179 Err(crate::CliError::new(
1180 ExitKind::Generic,
1181 "HEALTH_STRICT_VIOLATIONS",
1182 format!("strict mode: tier-2 violations present ({summary})"),
1183 )
1184 .into())
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use clap::CommandFactory;
1191
1192 #[test]
1193 fn help_lists_every_include_key() {
1194 let cmd = Args::command();
1195 let arg = cmd
1196 .get_arguments()
1197 .find(|a| a.get_id() == "include")
1198 .expect("--include arg must exist");
1199 let help = arg
1200 .get_help()
1201 .expect("--include must have help text")
1202 .to_string();
1203 for key in HEALTH_INCLUDE_KEYS {
1204 assert!(
1205 help.contains(key),
1206 "`memstead health --help` must name include key `{key}` (got: {help})"
1207 );
1208 }
1209 }
1210}