1use std::collections::HashMap;
18
19#[derive(Debug)]
23pub struct HealthArgs<'a> {
24 pub mem: Option<&'a str>,
25 pub include: &'a [String],
26 pub limit: Option<usize>,
27 pub target_schema: Option<&'a str>,
28 pub include_config: bool,
29}
30
31#[derive(Debug, Clone)]
36pub struct HealthConfig {
37 pub mutations: serde_json::Value,
38 pub plugin: serde_json::Value,
39}
40
41#[allow(clippy::large_enum_variant)]
48#[derive(Debug, thiserror::Error)]
49pub enum ComposeHealthError {
50 #[error("unknown mem: \"{name}\"")]
54 UnknownMem {
55 name: String,
56 writable_mems: Vec<String>,
57 },
58 #[error("mem \"{0}\" is quarantined")]
63 MemQuarantined(String),
64 #[error("invalid target_schema {raw:?}: {reason}")]
68 InvalidTargetSchema { raw: String, reason: String },
69 #[error(transparent)]
72 Engine(#[from] crate::EngineError),
73}
74
75pub fn compose_health(
80 engine: &mut crate::Engine,
81 args: &HealthArgs,
82 drift_warnings: Vec<crate::WarningHint>,
83 config: &HealthConfig,
84) -> Result<serde_json::Value, ComposeHealthError> {
85 let health = engine.health();
86 let stats = engine.status();
87 let include = args.include;
88 const HEALTH_LIMIT_MAX: usize = 100;
89 let requested_limit = args.limit.unwrap_or(10);
90 let limit = requested_limit.min(HEALTH_LIMIT_MAX);
91
92 let mut warnings: Vec<crate::WarningHint> = drift_warnings;
93 warnings.extend(health.warnings.clone());
94 if requested_limit > HEALTH_LIMIT_MAX {
95 warnings.push(crate::WarningHint::LimitClamped {
96 requested: requested_limit,
97 actual: HEALTH_LIMIT_MAX,
98 });
99 }
100
101 for key in include {
102 if !crate::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
103 warnings.push(crate::WarningHint::UnknownIncludeKey {
104 key: key.clone(),
105 allowed: crate::ops::health::HEALTH_INCLUDE_KEYS
106 .iter()
107 .map(|s| s.to_string())
108 .collect(),
109 });
110 }
111 }
112
113 let mem_filter: Option<String> = match args.mem {
115 Some(v) if engine.mem_router().is_writable(v) => Some(v.to_string()),
116 Some(v) if engine.quarantine_reason(v).is_some() => {
117 return Err(ComposeHealthError::MemQuarantined(v.to_string()));
118 }
119 Some(v) => {
120 let mut names: Vec<String> = engine
121 .mem_router()
122 .writable_mems()
123 .iter()
124 .cloned()
125 .collect();
126 names.sort();
127 return Err(ComposeHealthError::UnknownMem {
128 name: v.to_string(),
129 writable_mems: names,
130 });
131 }
132 None => None,
133 };
134 let vf = mem_filter.as_deref();
135
136 if let Some(v) = vf {
143 warnings.retain(|w| w.concerns_mem(v));
144 }
145
146 let in_mem = |e: &crate::Entity| -> bool {
147 match vf {
148 Some(v) => e.mem == v,
149 None => true,
150 }
151 };
152 let real_count = engine
153 .store()
154 .all_entities()
155 .filter(|e| !e.stub && in_mem(e))
156 .count();
157 let stub_count = engine
158 .store()
159 .all_entities()
160 .filter(|e| e.stub && in_mem(e))
161 .count();
162 let total_count = real_count + stub_count;
163
164 let orphan_ids: Vec<crate::EntityId> = engine
165 .orphans()
166 .into_iter()
167 .filter(|id| match vf {
168 Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
169 None => true,
170 })
171 .collect();
172 let stub_pairs: Vec<(crate::EntityId, Vec<crate::EntityId>)> = engine
173 .stubs()
174 .into_iter()
175 .filter(|(id, _)| match vf {
176 Some(v) => engine.store().get(id).map(|e| e.mem == v).unwrap_or(false),
177 None => true,
178 })
179 .collect();
180
181 let community_count = match vf {
187 Some(v) => {
188 crate::graph::community::clusters_in_mem(engine.store(), engine.communities(), v).len()
189 }
190 None => engine.communities().count,
191 };
192
193 let (edge_count, edge_types) = {
196 if let Some(v) = vf {
197 let mut counts: HashMap<String, usize> = HashMap::new();
198 let mut total: usize = 0;
199 for id in engine.store().all_ids() {
200 let source_mem = engine.store().get(id).map(|e| e.mem.clone());
201 if let Some(source) = source_mem.as_deref()
202 && source != v
203 {
204 continue;
205 }
206 for edge in engine.store().outgoing(id) {
207 *counts.entry(edge.rel_type.clone()).or_insert(0) += 1;
208 total += 1;
209 }
210 }
211 let mut pairs: Vec<_> = counts.into_iter().collect();
213 pairs.sort_by(|a, b| a.0.cmp(&b.0));
214 let arr: Vec<serde_json::Value> = pairs
215 .into_iter()
216 .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
217 .collect();
218 (total, arr)
219 } else {
220 let arr: Vec<serde_json::Value> = stats
222 .edge_types
223 .iter()
224 .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
225 .collect();
226 (stats.edge_count, arr)
227 }
228 };
229
230 let type_distribution: Vec<serde_json::Value> = {
231 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
232 for e in engine
233 .store()
234 .all_entities()
235 .filter(|e| !e.stub && in_mem(e))
236 {
237 *counts.entry(&e.entity_type).or_default() += 1;
238 }
239 let mut pairs: Vec<_> = counts.into_iter().collect();
240 pairs.sort_by(|a, b| a.0.cmp(b.0));
241 pairs
242 .into_iter()
243 .map(|(s, c)| serde_json::json!({"type": s, "count": c}))
244 .collect()
245 };
246
247 let writable_mems: Vec<String> = {
248 let mut names: Vec<String> = engine
249 .mem_router()
250 .writable_mems()
251 .iter()
252 .cloned()
253 .collect();
254 names.sort();
255 names
256 };
257 let default_writable_mem: Option<String> = engine.default_writable_mem().map(|s| s.to_string());
262 let read_mems: Vec<String> = {
263 let writable_set: std::collections::HashSet<&String> =
264 engine.mem_router().writable_mems().iter().collect();
265 let mut names: Vec<String> = engine
266 .mem_router()
267 .visible_mems()
268 .iter()
269 .filter(|n| !writable_set.contains(*n))
270 .cloned()
271 .collect();
272 names.sort();
273 names
274 };
275
276 let mem_schemas: Vec<serde_json::Value> = {
289 let mut entries: Vec<serde_json::Value> = Vec::new();
290 let writable_set: std::collections::HashSet<&String> = writable_mems.iter().collect();
291 for name in writable_mems.iter().chain(read_mems.iter()) {
292 if let Some(v) = vf
293 && name != v
294 {
295 continue;
296 }
297 if let Some(m) = engine.mount(name) {
298 let schema_ref = m
303 .schema
304 .as_ref()
305 .map(|s| s.as_display())
306 .unwrap_or_default();
307 let mut entry = serde_json::json!({
308 "mem": name,
309 "schema": schema_ref,
310 "writable": writable_set.contains(name),
311 });
312 if let Some(target) = &m.migration_target {
316 entry["migration_target"] = serde_json::json!(target.as_display());
317 }
318 entries.push(entry);
319 }
320 }
321 entries
322 };
323
324 let orphans_by_schema = engine.orphans_by_schema(&orphan_ids);
335 let scope_mems: Vec<String> = match vf {
336 Some(v) => vec![v.to_string()],
337 None => writable_mems
338 .iter()
339 .chain(read_mems.iter())
340 .cloned()
341 .collect(),
342 };
343 let communities_by_schema = engine.communities_by_schema(&scope_mems);
344
345 let mut result = serde_json::json!({
346 "mem": mem_filter,
347 "verdict_coverage": crate::ops::coverage::HEALTH_COVERAGE.wire_line(),
361 "summary": {
362 "total_entities": real_count,
363 "total_orphans": orphan_ids.len(),
364 "total_stubs": stub_pairs.len(),
365 "total_stale": health.stale_entities.iter().filter(|e| match vf {
366 Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
367 None => true,
368 }).count(),
369 "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
370 Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
371 None => true,
372 }).count(),
373 "total_communities": community_count,
374 "orphans_by_schema": orphans_by_schema,
375 "communities_by_schema": communities_by_schema,
376 },
377 "total_nodes": total_count,
378 "real_nodes": real_count,
379 "stub_nodes": stub_count,
380 "total_edges": edge_count,
381 "edge_types": edge_types,
382 "type_distribution": type_distribution,
383 "writable_mems": writable_mems,
384 "default_writable_mem": default_writable_mem,
385 "read_mems": read_mems,
386 "mem_schemas": mem_schemas,
387 });
388 let obj = result.as_object_mut().unwrap();
389 if let Some(v) = vf
393 && let Some(schema) = crate::overview::mem_schema_ref(engine, v)
394 {
395 obj.insert("_mem_schema".into(), serde_json::Value::String(schema));
396 }
397 if !warnings.is_empty() {
398 obj.insert("warnings".into(), serde_json::json!(warnings));
399 }
400 if !health.quarantined.is_empty() {
403 obj.insert(
404 "quarantined".into(),
405 serde_json::to_value(&health.quarantined).unwrap_or_default(),
406 );
407 }
408 if !health.load_errors.is_empty() {
413 obj.insert(
414 "load_errors".into(),
415 serde_json::to_value(&health.load_errors).unwrap_or_default(),
416 );
417 }
418 if let Some(diag) = &health.boot_diagnosis {
419 obj.insert("boot_diagnosis".into(), diag.clone());
420 }
421 if !health.leaf_entities_by_type.is_empty() {
424 obj.insert(
425 "leaf_entities_by_type".into(),
426 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
427 );
428 }
429
430 if include.iter().any(|s| s == "orphans") {
431 let orphans_list: Vec<serde_json::Value> = orphan_ids
432 .into_iter()
433 .map(|id| {
434 let title = engine
435 .get_entity(&id)
436 .map(|e| e.title.clone())
437 .unwrap_or_default();
438 serde_json::json!({"id": id.to_string(), "title": title})
439 })
440 .collect();
441 obj.insert("orphans".into(), serde_json::json!(orphans_list));
442 }
443 if include.iter().any(|s| s == "stubs") {
444 let stubs_list: Vec<serde_json::Value> = stub_pairs
445 .into_iter()
446 .map(|(id, refs)| {
447 serde_json::json!({
448 "id": id.to_string(),
449 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
450 })
451 })
452 .collect();
453 obj.insert("stubs".into(), serde_json::json!(stubs_list));
454 }
455 if include.iter().any(|s| s == "most_connected") {
456 use crate::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
457 let to_json = |c: Connectivity| {
462 let title = engine
463 .get_entity(&c.id)
464 .map(|e| e.title.clone())
465 .unwrap_or_default();
466 serde_json::json!({
467 "id": c.id.to_string(),
468 "title": title,
469 "total": c.total,
470 "incoming": c.incoming,
471 "outgoing": c.outgoing,
472 "typed_total": c.typed_total,
473 "typed_incoming": c.typed_incoming,
474 "typed_outgoing": c.typed_outgoing,
475 })
476 };
477 let connected: Vec<serde_json::Value> = if let Some(v) = vf {
478 let mut entries: Vec<Connectivity> = engine
484 .store()
485 .all_entities()
486 .filter(|e| !e.stub && e.mem == v)
487 .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
488 .collect();
489 entries.sort_by(cmp_by_dependency);
490 entries.truncate(limit);
491 entries.into_iter().map(to_json).collect()
492 } else {
493 engine
494 .most_connected(limit)
495 .into_iter()
496 .map(to_json)
497 .collect()
498 };
499 obj.insert("most_connected".into(), serde_json::json!(connected));
500 }
501 if include.iter().any(|s| s == "missing_fields") {
502 let missing_fields: Vec<serde_json::Value> = health
503 .missing_fields
504 .iter()
505 .filter(|h| match vf {
506 Some(v) => engine
507 .store()
508 .get(&h.id)
509 .map(|e| e.mem == v)
510 .unwrap_or(false),
511 None => true,
512 })
513 .map(|h| {
514 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
520 let issues: Vec<serde_json::Value> = h
521 .issues
522 .iter()
523 .map(|i| {
524 serde_json::json!({
525 "field": i.field,
526 "code": i.code,
527 "message": i.message,
528 })
529 })
530 .collect();
531 serde_json::json!({
532 "id": h.id.to_string(),
533 "title": h.title,
534 "missing": missing,
535 "issues": issues,
536 })
537 })
538 .collect();
539 obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
540 }
541 if include.iter().any(|s| s == "stale") {
542 let stale: Vec<serde_json::Value> = health
543 .stale_entities
544 .iter()
545 .filter(|e| match vf {
546 Some(v) => engine
547 .store()
548 .get(&e.id)
549 .map(|ent| ent.mem == v)
550 .unwrap_or(false),
551 None => true,
552 })
553 .map(stale_row)
554 .collect();
555 obj.insert("stale".into(), serde_json::json!(stale));
556 let fresh: Vec<serde_json::Value> = health
559 .anchor_fresh
560 .iter()
561 .filter(|e| match vf {
562 Some(v) => engine
563 .store()
564 .get(&e.id)
565 .map(|ent| ent.mem == v)
566 .unwrap_or(false),
567 None => true,
568 })
569 .map(stale_row)
570 .collect();
571 if !fresh.is_empty() {
572 obj.insert("anchor_fresh".into(), serde_json::json!(fresh));
573 }
574 }
575 if include.iter().any(|s| s == "dangling_links") {
576 let dangling = crate::ops::health::collect_dangling_links(engine.store(), vf);
577 let arr: Vec<serde_json::Value> = dangling
578 .into_iter()
579 .map(|dl| serde_json::to_value(&dl).unwrap())
580 .collect();
581 obj.insert("dangling_links".into(), serde_json::json!(arr));
582 }
583 if include.iter().any(|s| s == "anchors") {
584 obj.insert(
585 "anchors".into(),
586 crate::ops::health::health_anchors_axis(engine, vf),
587 );
588 }
589 if include.iter().any(|s| s == "stale_derivations") {
590 obj.insert(
591 "stale_derivations".into(),
592 crate::ops::health::health_stale_derivations_axis(engine, args.mem),
593 );
594 }
595 if include.iter().any(|s| s == "checks") {
596 obj.insert(
597 "checks".into(),
598 crate::ops::health::health_checks_axis(engine, args.mem),
599 );
600 }
601 if include.iter().any(|s| s == "signals") {
602 obj.insert("signals".into(), engine.health_signals_axis(args.mem));
606 }
607 if include.iter().any(|s| s == "labelling") {
608 obj.insert("labelling".into(), engine.health_labelling_axis(args.mem));
612 }
613 if include.iter().any(|s| s == "open_questions") {
614 obj.insert(
615 "open_questions".into(),
616 crate::ops::health::health_open_questions_axis(engine, args.mem),
617 );
618 }
619 if include.iter().any(|s| s == "vital_signs") {
620 obj.insert(
623 "vital_signs".into(),
624 crate::ops::health::health_vital_signs_axis(engine, args.mem),
625 );
626 }
627 if include.iter().any(|s| s == "friction") {
628 let summary = match engine.workspace_root() {
634 Some(root) => crate::friction::FrictionLedger::for_workspace(root).summarize(),
635 None => serde_json::json!({
636 "total": 0,
637 "by_code": {},
638 "by_verb": {},
639 "recent_24h": { "total": 0, "by_code": {} },
640 "ledger_bytes": 0,
641 }),
642 };
643 obj.insert("friction".into(), summary);
644 }
645 if include.iter().any(|s| s == "missing_required_outgoing") {
646 let reports = engine.missing_required_outgoing(vf);
647 let arr: Vec<serde_json::Value> = reports
648 .into_iter()
649 .map(|r| serde_json::to_value(&r).unwrap())
650 .collect();
651 obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
652 }
653 if include.iter().any(|s| s == "constraints") {
654 let reports = engine.constraint_findings(vf);
655 let arr: Vec<serde_json::Value> = reports
656 .into_iter()
657 .map(|r| serde_json::to_value(&r).unwrap())
658 .collect();
659 obj.insert("constraints".into(), serde_json::json!(arr));
660 let defects = engine.schema_format_defects();
661 if !defects.is_empty() {
662 obj.insert(
663 "schema_format_defects".into(),
664 serde_json::to_value(&defects).unwrap(),
665 );
666 }
667 }
668 if include.iter().any(|s| s == "tags") {
669 let (distribution, folded, untagged) =
670 crate::ops::health::collect_tag_distribution(engine.store(), vf, limit);
671 obj.insert(
672 "tag_distribution".into(),
673 serde_json::to_value(&distribution).unwrap(),
674 );
675 obj.insert(
676 "tag_distribution_folded".into(),
677 serde_json::to_value(&folded).unwrap(),
678 );
679 obj.insert(
680 "untagged_entities".into(),
681 serde_json::to_value(&untagged).unwrap(),
682 );
683 }
684 let wants_conformance = include
690 .iter()
691 .any(|s| s == "conformance" || s == "integrity");
692 if wants_conformance {
693 let wants_consistency = include.iter().any(|s| s == "integrity");
694 let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
695 None => None,
696 Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
697 Ok(r) => Some(r),
698 Err(reason) => {
699 return Err(ComposeHealthError::InvalidTargetSchema {
700 raw: raw.to_string(),
701 reason,
702 });
703 }
704 },
705 };
706 let scan_mems: Vec<String> = match vf {
707 Some(v) => vec![v.to_string()],
708 None => {
709 let mut all = writable_mems.clone();
710 all.sort();
711 all
712 }
713 };
714 let mut findings = Vec::new();
715 let mut observations = Vec::new();
716 for v in &scan_mems {
717 findings.extend(engine.conformance_findings(v, target.as_ref())?);
718 observations.extend(engine.body_observations(v, target.as_ref())?);
722 if wants_consistency {
723 findings.extend(engine.consistency_findings(v)?);
724 }
725 }
726 obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
727 obj.insert(
728 "body_observations".into(),
729 serde_json::to_value(&observations).unwrap(),
730 );
731 }
732
733 if include.iter().any(|s| s == "ledger") {
740 let mut ledger = engine.ledger_reconciliation();
741 if let Some(v) = vf {
742 ledger.retain(|mem, _| mem == v);
743 }
744 obj.insert(
745 "ledger".into(),
746 serde_json::to_value(ledger).unwrap_or_default(),
747 );
748 }
749
750 if args.include_config || include.iter().any(|s| s == "config") {
759 let config_mems: Vec<String> = match vf {
762 Some(v) => writable_mems.iter().filter(|m| *m == v).cloned().collect(),
763 None => writable_mems.clone(),
764 };
765 let entries = crate::ops::health::config_projection(
766 engine,
767 &config_mems,
768 config.mutations.clone(),
769 config.plugin.clone(),
770 );
771 for (k, v) in entries {
772 obj.insert(k, v);
773 }
774 }
775
776 Ok(result)
777}
778
779pub fn render_health_markdown(v: &serde_json::Value) -> String {
787 use std::fmt::Write as _;
788 let mut s = String::new();
789 let _ = writeln!(s, "# Graph health");
790 if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
791 let _ = writeln!(s, "\nMem filter: `{mem}`");
792 }
793
794 if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
795 let _ = writeln!(s, "\n## Summary");
796 for key in [
797 "total_entities",
798 "total_orphans",
799 "total_stubs",
800 "total_stale",
801 "total_missing_fields",
802 "total_communities",
803 ] {
804 if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
805 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
806 }
807 }
808 render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
809 render_count_map(
810 &mut s,
811 sum.get("communities_by_schema"),
812 "Communities by schema",
813 );
814 }
815
816 for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
817 if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
818 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
819 }
820 }
821
822 for (key, title) in [
824 ("orphans", "Orphans"),
825 ("stubs", "Stubs"),
826 ("most_connected", "Most connected"),
827 ("missing_fields", "Missing fields"),
828 ("stale", "Stale"),
829 ("dangling_links", "Dangling links"),
830 ("missing_required_outgoing", "Missing required outgoing"),
831 ("constraints", "Constraint violations"),
832 ("findings", "Findings"),
833 ] {
834 if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
835 let _ = writeln!(s, "\n## {title} ({})", arr.len());
836 for item in arr {
837 let _ = writeln!(s, "- {}", summarize_health_item(item));
838 }
839 }
840 }
841
842 if let Some(arr) = v.get("body_observations").and_then(|x| x.as_array()) {
847 let _ = writeln!(s, "\n## Body observations ({})", arr.len());
848 for item in arr {
849 let detail = &item["detail"];
850 let subject = detail
851 .get("heading")
852 .or_else(|| detail.get("key"))
853 .and_then(|x| x.as_str())
854 .unwrap_or("");
855 let _ = writeln!(
856 s,
857 "- {} [{}] `{subject}`: {} ({})",
858 item["id"].as_str().unwrap_or(""),
859 item["code"].as_str().unwrap_or(""),
860 item["fate"].as_str().unwrap_or(""),
861 detail
862 .get("note")
863 .and_then(|x| x.as_str())
864 .unwrap_or("no note"),
865 );
866 }
867 }
868
869 if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
875 let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
876 for (mem, counts) in obj {
877 if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
878 let _ = writeln!(
879 s,
880 "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
881 c["reason"].as_str().unwrap_or("reason not stated"),
882 counts["population"]
883 .as_str()
884 .unwrap_or("population not stated"),
885 );
886 continue;
887 }
888 let _ = writeln!(
889 s,
890 "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
891 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
892 counts["resolves"].as_u64().unwrap_or(0),
893 counts["drifted"].as_u64().unwrap_or(0),
894 counts["recheck"].as_u64().unwrap_or(0),
895 counts["unresolvable"].as_u64().unwrap_or(0),
896 counts["unobserved"].as_u64().unwrap_or(0),
897 counts["dangling"].as_u64().unwrap_or(0),
898 counts["population"]
899 .as_str()
900 .unwrap_or("population not stated"),
901 );
902 }
903 }
904
905 if let Some(obj) = v.get("vital_signs").and_then(|x| x.as_object()) {
908 let mems: Vec<(&String, &serde_json::Value)> =
909 obj.iter().filter(|(k, _)| *k != "_item_cap").collect();
910 let _ = writeln!(s, "\n## Vital signs ({} mems)", mems.len());
911 for (mem, sig) in mems {
912 let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
913 let share = match sig["type_share_by_community"]["status"].as_str() {
914 Some("declared") => format!(
915 "last-resort type `{}` over {} communit{}",
916 sig["type_share_by_community"]["last_resort_type"]
917 .as_str()
918 .unwrap_or("?"),
919 count("type_share_by_community"),
920 if count("type_share_by_community") == 1 {
921 "y"
922 } else {
923 "ies"
924 }
925 ),
926 _ => "last-resort type not declared".to_string(),
927 };
928 let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
929 Some("enumerated") => format!(
930 "{} unclaimed source file(s)",
931 count("unclaimed_source_files")
932 ),
933 _ => "no bound source".to_string(),
934 };
935 let _ = writeln!(
936 s,
937 "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
938 entit{} in {} communit{}; {} empty declared section(s)",
939 count("contested_unowned_files"),
940 sig["zero_outgoing_entities"]["entities"]
941 .as_u64()
942 .unwrap_or(0),
943 if sig["zero_outgoing_entities"]["entities"]
944 .as_u64()
945 .unwrap_or(0)
946 == 1
947 {
948 "y"
949 } else {
950 "ies"
951 },
952 count("zero_outgoing_entities"),
953 if count("zero_outgoing_entities") == 1 {
954 "y"
955 } else {
956 "ies"
957 },
958 count("empty_declared_sections"),
959 );
960 }
961 }
962
963 if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
968 let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
969 for (mem, c) in obj {
970 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
971 let conf = |key: &str| {
972 c.get("conformance")
973 .and_then(|g| g.get(key))
974 .and_then(|x| x.as_u64())
975 .unwrap_or(0)
976 };
977 let gate = |key: &str| {
978 c.get("independence")
979 .and_then(|g| g.get(key))
980 .and_then(|e| e.get("count"))
981 .and_then(|x| x.as_u64())
982 .unwrap_or(0)
983 };
984 let _ = writeln!(
985 s,
986 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
987 check_stale {}; conformance: never_checked {}, \
988 checked_ok {}, check_failed {}, check_stale {}; \
989 independence: self_checked {}, \
990 confirmed_independent {}, unconfirmable {}",
991 count("never_checked"),
992 count("checked_ok"),
993 count("check_failed"),
994 count("check_stale"),
995 conf("never_checked"),
996 conf("checked_ok"),
997 conf("check_failed"),
998 conf("check_stale"),
999 gate("self_checked"),
1000 gate("confirmed_independent"),
1001 gate("unconfirmable"),
1002 );
1003 if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
1007 && !foreign.is_empty()
1008 {
1009 let listed: Vec<String> = foreign
1010 .iter()
1011 .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
1012 .collect();
1013 let _ = writeln!(s, " - foreign kinds: {}", listed.join(", "));
1014 }
1015 if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
1016 for (entity, f) in findings {
1017 let code = f["finding"]["code"].as_str().unwrap_or("?");
1018 let section = f["finding"]["section"]
1019 .as_str()
1020 .map(|x| format!(" [{x}]"))
1021 .unwrap_or_default();
1022 let message = f["finding"]["message"].as_str().unwrap_or("");
1023 let _ = writeln!(
1024 s,
1025 " - finding on `{entity}` ({} {}): {code}{section} — {message}",
1026 f["kind"].as_str().unwrap_or("verification"),
1027 f["verdict"].as_str().unwrap_or("?"),
1028 );
1029 }
1030 }
1031 }
1032 }
1033
1034 if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
1037 let total: usize = obj
1038 .values()
1039 .filter_map(|a| a.as_array().map(|a| a.len()))
1040 .sum();
1041 let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
1042 for (mem, findings) in obj {
1043 for f in findings.as_array().into_iter().flatten() {
1044 let _ = writeln!(
1045 s,
1046 "- `{mem}`: {} -[{}]-> {} ({})",
1047 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1048 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1049 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1050 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1051 );
1052 }
1053 }
1054 }
1055
1056 if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
1061 let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
1062 for q in arr {
1063 let _ = writeln!(
1064 s,
1065 "- `{}` [{}] {}",
1066 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1067 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1068 q.get("reason_message")
1069 .and_then(|x| x.as_str())
1070 .unwrap_or(""),
1071 );
1072 }
1073 }
1074
1075 if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
1076 && !arr.is_empty()
1077 {
1078 let _ = writeln!(s, "\n## Warnings ({})", arr.len());
1079 for w in arr {
1080 let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
1081 let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
1082 let _ = writeln!(s, "- [{code}] {msg}");
1083 }
1084 }
1085
1086 s
1087}
1088
1089fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
1093 use std::fmt::Write as _;
1094 let Some(map) = val.and_then(|x| x.as_object()) else {
1095 return;
1096 };
1097 if map.is_empty() {
1098 return;
1099 }
1100 let _ = writeln!(s, "- {title}:");
1101 for (k, n) in map {
1102 let label = if k.is_empty() {
1103 "(unpinned)"
1104 } else {
1105 k.as_str()
1106 };
1107 let _ = writeln!(s, " - {label}: {}", n.as_u64().unwrap_or(0));
1108 }
1109}
1110
1111fn summarize_health_item(item: &serde_json::Value) -> String {
1123 if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
1124 match item.get("title").and_then(|x| x.as_str()) {
1125 Some(t) if !t.is_empty() => format!("{id} — {t}"),
1126 _ => id.to_string(),
1127 }
1128 } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
1129 let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
1130 match item.get("kind").and_then(|x| x.as_str()) {
1131 Some(kind) => format!("[{kind}] {from} → {target}"),
1132 None => format!("{from} → {target}"),
1133 }
1134 } else {
1135 serde_json::to_string(item).unwrap_or_default()
1136 }
1137}
1138
1139fn stale_row(e: &crate::ops::StaleEntity) -> serde_json::Value {
1144 let mut row = serde_json::json!({
1145 "id": e.id.to_string(),
1146 "title": e.title,
1147 "days_since_modified": e.days_since_modified,
1148 });
1149 if let Some(state) = &e.anchor_state {
1150 let obj = row.as_object_mut().unwrap();
1151 obj.insert("clock".into(), serde_json::json!("anchors"));
1152 obj.insert("anchor_state".into(), serde_json::json!(state));
1153 }
1154 row
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::render_health_markdown;
1160 use serde_json::json;
1161
1162 fn base_payload() -> serde_json::Value {
1163 json!({
1164 "summary": { "total_entities": 1 },
1165 "total_nodes": 1,
1166 })
1167 }
1168
1169 #[test]
1176 fn body_observations_render_their_code_and_fate_not_just_an_id() {
1177 let mut v = base_payload();
1178 v["body_observations"] = json!([{
1179 "id": "specs--alpha",
1180 "code": "ABSORBED_SECTION",
1181 "fate": "absorbed",
1182 "detail": { "heading": "Rogue", "note": "survives the next write" },
1183 }, {
1184 "id": "specs--beta",
1185 "code": "UNDECLARED_METADATA_KEY",
1186 "fate": "dropped",
1187 "detail": { "key": "reviewer", "note": "the next write drops it" },
1188 }]);
1189 let md = render_health_markdown(&v);
1190 assert!(md.contains("## Body observations (2)"), "{md}");
1191 assert!(
1192 md.contains(
1193 "- specs--alpha [ABSORBED_SECTION] `Rogue`: absorbed (survives the next write)"
1194 ),
1195 "{md}"
1196 );
1197 assert!(
1198 md.contains(
1199 "- specs--beta [UNDECLARED_METADATA_KEY] `reviewer`: dropped \
1200 (the next write drops it)"
1201 ),
1202 "{md}"
1203 );
1204 assert!(!render_health_markdown(&base_payload()).contains("Body observations"));
1206 }
1207
1208 #[test]
1215 fn render_health_markdown_names_the_dangling_condition() {
1216 let mut v = base_payload();
1217 v["dangling_links"] = json!([
1218 {
1219 "kind": "DANGLING_LINK_TARGET_MISSING",
1220 "from": "specs--a", "target_id": "specs--gone",
1221 "target_path": "gone", "section": "purpose",
1222 },
1223 {
1224 "kind": "DANGLING_RELATION_TARGET_MISSING",
1225 "from": "specs--b", "target_id": "specs--vanished",
1226 "target_path": "vanished", "section": null,
1227 },
1228 ]);
1229 let md = render_health_markdown(&v);
1230 assert!(
1231 md.contains("[DANGLING_LINK_TARGET_MISSING] specs--a → specs--gone"),
1232 "{md}"
1233 );
1234 assert!(
1235 md.contains("[DANGLING_RELATION_TARGET_MISSING] specs--b → specs--vanished"),
1236 "{md}"
1237 );
1238 assert!(
1240 !md.contains("- specs--a → specs--gone"),
1241 "the unprefixed form is what fused them: {md}"
1242 );
1243 }
1244
1245 #[test]
1252 fn render_health_markdown_covers_checks_derivations_and_quarantine() {
1253 let mut v = base_payload();
1255 v["checks"] = json!({
1256 "specs": {
1257 "never_checked": 2, "checked_ok": 1,
1258 "check_failed": 0, "check_stale": 0,
1259 "conformance": {
1260 "never_checked": 3, "checked_ok": 0,
1261 "check_failed": 0, "check_stale": 0,
1262 },
1263 "independence": {
1264 "self_checked": { "count": 0, "items": [] },
1265 "confirmed_independent": { "count": 0, "items": [] },
1266 "unconfirmable": { "count": 1, "items": ["specs--a"] },
1267 },
1268 }
1269 });
1270 v["stale_derivations"] = json!({
1271 "specs": [{
1272 "source": "specs--a", "rel_type": "DERIVES_FROM",
1273 "target": "specs--b", "state": "stale",
1274 "baseline": "aaa", "current": "bbb",
1275 }]
1276 });
1277 v["quarantined"] = json!([{
1278 "mem": "broken",
1279 "reason_code": "SCHEMA_NOT_FOUND",
1280 "reason_message": "no schema; repair via memstead mem set-schema",
1281 }]);
1282 let md = render_health_markdown(&v);
1283 assert!(md.contains("## Checks (1 mems)"), "{md}");
1284 assert!(
1285 md.contains(
1286 "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
1287 check_stale 0; conformance: never_checked 3, checked_ok 0, \
1288 check_failed 0, check_stale 0; independence: self_checked 0, \
1289 confirmed_independent 0, unconfirmable 1"
1290 ),
1291 "{md}"
1292 );
1293 assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
1294 assert!(
1295 md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
1296 "{md}"
1297 );
1298 assert!(md.contains("## Quarantined mems (1)"), "{md}");
1299 assert!(
1300 md.contains(
1301 "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
1302 ),
1303 "{md}"
1304 );
1305
1306 let mut empty = base_payload();
1308 empty["checks"] = json!({});
1309 empty["stale_derivations"] = json!({ "specs": [] });
1310 let md = render_health_markdown(&empty);
1311 assert!(md.contains("## Checks (0 mems)"), "{md}");
1312 assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
1313
1314 let base_md = render_health_markdown(&base_payload());
1317 for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
1318 assert!(
1319 !base_md.contains(heading),
1320 "absent key must render nothing: {base_md}"
1321 );
1322 }
1323 let appended = render_health_markdown(&v);
1324 assert!(
1325 appended.starts_with(&base_md),
1326 "sections append; the base output stays byte-identical"
1327 );
1328 }
1329}