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.source_mem().is_none_or(|wv| wv == 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_promoting(
354 if include.iter().any(|s| s == "anchors") { &["anchors"] } else { &[] },
355 ),
356 "summary": {
357 "total_entities": real_count,
358 "total_orphans": orphan_ids.len(),
359 "total_stubs": stub_pairs.len(),
360 "total_stale": health.stale_entities.iter().filter(|e| match vf {
361 Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
362 None => true,
363 }).count(),
364 "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
365 Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
366 None => true,
367 }).count(),
368 "total_communities": community_count,
369 "orphans_by_schema": orphans_by_schema,
370 "communities_by_schema": communities_by_schema,
371 },
372 "total_nodes": total_count,
373 "real_nodes": real_count,
374 "stub_nodes": stub_count,
375 "total_edges": edge_count,
376 "edge_types": edge_types,
377 "type_distribution": type_distribution,
378 "writable_mems": writable_mems,
379 "default_writable_mem": default_writable_mem,
380 "read_mems": read_mems,
381 "mem_schemas": mem_schemas,
382 });
383 let obj = result.as_object_mut().unwrap();
384 if let Some(v) = vf
388 && let Some(schema) = crate::overview::mem_schema_ref(engine, v)
389 {
390 obj.insert("_mem_schema".into(), serde_json::Value::String(schema));
391 }
392 if !warnings.is_empty() {
393 obj.insert("warnings".into(), serde_json::json!(warnings));
394 }
395 if !health.quarantined.is_empty() {
398 obj.insert(
399 "quarantined".into(),
400 serde_json::to_value(&health.quarantined).unwrap_or_default(),
401 );
402 }
403 if !health.load_errors.is_empty() {
408 obj.insert(
409 "load_errors".into(),
410 serde_json::to_value(&health.load_errors).unwrap_or_default(),
411 );
412 }
413 if let Some(diag) = &health.boot_diagnosis {
414 obj.insert("boot_diagnosis".into(), diag.clone());
415 }
416 if !health.leaf_entities_by_type.is_empty() {
419 obj.insert(
420 "leaf_entities_by_type".into(),
421 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
422 );
423 }
424
425 if include.iter().any(|s| s == "orphans") {
426 let orphans_list: Vec<serde_json::Value> = orphan_ids
427 .into_iter()
428 .map(|id| {
429 let title = engine
430 .get_entity(&id)
431 .map(|e| e.title.clone())
432 .unwrap_or_default();
433 serde_json::json!({"id": id.to_string(), "title": title})
434 })
435 .collect();
436 obj.insert("orphans".into(), serde_json::json!(orphans_list));
437 }
438 if include.iter().any(|s| s == "stubs") {
439 let stubs_list: Vec<serde_json::Value> = stub_pairs
440 .into_iter()
441 .map(|(id, refs)| {
442 serde_json::json!({
443 "id": id.to_string(),
444 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
445 })
446 })
447 .collect();
448 obj.insert("stubs".into(), serde_json::json!(stubs_list));
449 }
450 if include.iter().any(|s| s == "most_connected") {
451 use crate::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
452 let to_json = |c: Connectivity| {
457 let title = engine
458 .get_entity(&c.id)
459 .map(|e| e.title.clone())
460 .unwrap_or_default();
461 serde_json::json!({
462 "id": c.id.to_string(),
463 "title": title,
464 "total": c.total,
465 "incoming": c.incoming,
466 "outgoing": c.outgoing,
467 "typed_total": c.typed_total,
468 "typed_incoming": c.typed_incoming,
469 "typed_outgoing": c.typed_outgoing,
470 })
471 };
472 let connected: Vec<serde_json::Value> = if let Some(v) = vf {
473 let mut entries: Vec<Connectivity> = engine
479 .store()
480 .all_entities()
481 .filter(|e| !e.stub && e.mem == v)
482 .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
483 .collect();
484 entries.sort_by(cmp_by_dependency);
485 entries.truncate(limit);
486 entries.into_iter().map(to_json).collect()
487 } else {
488 engine
489 .most_connected(limit)
490 .into_iter()
491 .map(to_json)
492 .collect()
493 };
494 obj.insert("most_connected".into(), serde_json::json!(connected));
495 }
496 if include.iter().any(|s| s == "missing_fields") {
497 let missing_fields: Vec<serde_json::Value> = health
498 .missing_fields
499 .iter()
500 .filter(|h| match vf {
501 Some(v) => engine
502 .store()
503 .get(&h.id)
504 .map(|e| e.mem == v)
505 .unwrap_or(false),
506 None => true,
507 })
508 .map(|h| {
509 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
515 let issues: Vec<serde_json::Value> = h
516 .issues
517 .iter()
518 .map(|i| {
519 serde_json::json!({
520 "field": i.field,
521 "code": i.code,
522 "message": i.message,
523 })
524 })
525 .collect();
526 serde_json::json!({
527 "id": h.id.to_string(),
528 "title": h.title,
529 "missing": missing,
530 "issues": issues,
531 })
532 })
533 .collect();
534 obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
535 }
536 if include.iter().any(|s| s == "stale") {
537 let stale: Vec<serde_json::Value> = health
538 .stale_entities
539 .iter()
540 .filter(|e| match vf {
541 Some(v) => engine
542 .store()
543 .get(&e.id)
544 .map(|ent| ent.mem == v)
545 .unwrap_or(false),
546 None => true,
547 })
548 .map(|e| {
549 serde_json::json!({
550 "id": e.id.to_string(),
551 "title": e.title,
552 "days_since_modified": e.days_since_modified,
553 })
554 })
555 .collect();
556 obj.insert("stale".into(), serde_json::json!(stale));
557 }
558 if include.iter().any(|s| s == "dangling_links") {
559 let dangling = crate::ops::health::collect_dangling_links(engine.store(), vf);
560 let arr: Vec<serde_json::Value> = dangling
561 .into_iter()
562 .map(|dl| serde_json::to_value(&dl).unwrap())
563 .collect();
564 obj.insert("dangling_links".into(), serde_json::json!(arr));
565 }
566 if include.iter().any(|s| s == "anchors") {
567 obj.insert(
568 "anchors".into(),
569 crate::ops::health::health_anchors_axis(engine),
570 );
571 }
572 if include.iter().any(|s| s == "stale_derivations") {
573 obj.insert(
574 "stale_derivations".into(),
575 crate::ops::health::health_stale_derivations_axis(engine, args.mem),
576 );
577 }
578 if include.iter().any(|s| s == "checks") {
579 obj.insert(
580 "checks".into(),
581 crate::ops::health::health_checks_axis(engine, args.mem),
582 );
583 }
584 if include.iter().any(|s| s == "signals") {
585 obj.insert("signals".into(), engine.health_signals_axis(args.mem));
589 }
590 if include.iter().any(|s| s == "labelling") {
591 obj.insert("labelling".into(), engine.health_labelling_axis(args.mem));
595 }
596 if include.iter().any(|s| s == "open_questions") {
597 obj.insert(
598 "open_questions".into(),
599 crate::ops::health::health_open_questions_axis(engine, args.mem),
600 );
601 }
602 if include.iter().any(|s| s == "vital_signs") {
603 obj.insert(
606 "vital_signs".into(),
607 crate::ops::health::health_vital_signs_axis(engine, args.mem),
608 );
609 }
610 if include.iter().any(|s| s == "friction") {
611 let summary = match engine.workspace_root() {
617 Some(root) => crate::friction::FrictionLedger::for_workspace(root).summarize(),
618 None => serde_json::json!({
619 "total": 0,
620 "by_code": {},
621 "by_verb": {},
622 "recent_24h": { "total": 0, "by_code": {} },
623 "ledger_bytes": 0,
624 }),
625 };
626 obj.insert("friction".into(), summary);
627 }
628 if include.iter().any(|s| s == "missing_required_outgoing") {
629 let reports = engine.missing_required_outgoing(vf);
630 let arr: Vec<serde_json::Value> = reports
631 .into_iter()
632 .map(|r| serde_json::to_value(&r).unwrap())
633 .collect();
634 obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
635 }
636 if include.iter().any(|s| s == "constraints") {
637 let reports = engine.constraint_findings(vf);
638 let arr: Vec<serde_json::Value> = reports
639 .into_iter()
640 .map(|r| serde_json::to_value(&r).unwrap())
641 .collect();
642 obj.insert("constraints".into(), serde_json::json!(arr));
643 let defects = engine.schema_format_defects();
644 if !defects.is_empty() {
645 obj.insert(
646 "schema_format_defects".into(),
647 serde_json::to_value(&defects).unwrap(),
648 );
649 }
650 }
651 if include.iter().any(|s| s == "tags") {
652 let (distribution, folded, untagged) =
653 crate::ops::health::collect_tag_distribution(engine.store(), vf, limit);
654 obj.insert(
655 "tag_distribution".into(),
656 serde_json::to_value(&distribution).unwrap(),
657 );
658 obj.insert(
659 "tag_distribution_folded".into(),
660 serde_json::to_value(&folded).unwrap(),
661 );
662 obj.insert(
663 "untagged_entities".into(),
664 serde_json::to_value(&untagged).unwrap(),
665 );
666 }
667 let wants_conformance = include
673 .iter()
674 .any(|s| s == "conformance" || s == "integrity");
675 if wants_conformance {
676 let wants_consistency = include.iter().any(|s| s == "integrity");
677 let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
678 None => None,
679 Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
680 Ok(r) => Some(r),
681 Err(reason) => {
682 return Err(ComposeHealthError::InvalidTargetSchema {
683 raw: raw.to_string(),
684 reason,
685 });
686 }
687 },
688 };
689 let scan_mems: Vec<String> = match vf {
690 Some(v) => vec![v.to_string()],
691 None => {
692 let mut all = writable_mems.clone();
693 all.sort();
694 all
695 }
696 };
697 let mut findings = Vec::new();
698 let mut observations = Vec::new();
699 for v in &scan_mems {
700 findings.extend(engine.conformance_findings(v, target.as_ref())?);
701 observations.extend(engine.body_observations(v, target.as_ref())?);
705 if wants_consistency {
706 findings.extend(engine.consistency_findings(v)?);
707 }
708 }
709 obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
710 obj.insert(
711 "body_observations".into(),
712 serde_json::to_value(&observations).unwrap(),
713 );
714 }
715
716 if include.iter().any(|s| s == "ledger") {
723 obj.insert(
724 "ledger".into(),
725 serde_json::to_value(engine.ledger_reconciliation()).unwrap_or_default(),
726 );
727 }
728
729 if args.include_config || include.iter().any(|s| s == "config") {
738 let entries = crate::ops::health::config_projection(
739 engine,
740 &writable_mems,
741 config.mutations.clone(),
742 config.plugin.clone(),
743 );
744 for (k, v) in entries {
745 obj.insert(k, v);
746 }
747 }
748
749 Ok(result)
750}
751
752pub fn render_health_markdown(v: &serde_json::Value) -> String {
760 use std::fmt::Write as _;
761 let mut s = String::new();
762 let _ = writeln!(s, "# Graph health");
763 if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
764 let _ = writeln!(s, "\nMem filter: `{mem}`");
765 }
766
767 if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
768 let _ = writeln!(s, "\n## Summary");
769 for key in [
770 "total_entities",
771 "total_orphans",
772 "total_stubs",
773 "total_stale",
774 "total_missing_fields",
775 "total_communities",
776 ] {
777 if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
778 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
779 }
780 }
781 render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
782 render_count_map(
783 &mut s,
784 sum.get("communities_by_schema"),
785 "Communities by schema",
786 );
787 }
788
789 for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
790 if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
791 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
792 }
793 }
794
795 for (key, title) in [
797 ("orphans", "Orphans"),
798 ("stubs", "Stubs"),
799 ("most_connected", "Most connected"),
800 ("missing_fields", "Missing fields"),
801 ("stale", "Stale"),
802 ("dangling_links", "Dangling links"),
803 ("missing_required_outgoing", "Missing required outgoing"),
804 ("constraints", "Constraint violations"),
805 ("findings", "Findings"),
806 ] {
807 if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
808 let _ = writeln!(s, "\n## {title} ({})", arr.len());
809 for item in arr {
810 let _ = writeln!(s, "- {}", summarize_health_item(item));
811 }
812 }
813 }
814
815 if let Some(arr) = v.get("body_observations").and_then(|x| x.as_array()) {
820 let _ = writeln!(s, "\n## Body observations ({})", arr.len());
821 for item in arr {
822 let detail = &item["detail"];
823 let subject = detail
824 .get("heading")
825 .or_else(|| detail.get("key"))
826 .and_then(|x| x.as_str())
827 .unwrap_or("");
828 let _ = writeln!(
829 s,
830 "- {} [{}] `{subject}`: {} ({})",
831 item["id"].as_str().unwrap_or(""),
832 item["code"].as_str().unwrap_or(""),
833 item["fate"].as_str().unwrap_or(""),
834 detail
835 .get("note")
836 .and_then(|x| x.as_str())
837 .unwrap_or("no note"),
838 );
839 }
840 }
841
842 if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
848 let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
849 for (mem, counts) in obj {
850 if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
851 let _ = writeln!(
852 s,
853 "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
854 c["reason"].as_str().unwrap_or("reason not stated"),
855 counts["population"]
856 .as_str()
857 .unwrap_or("population not stated"),
858 );
859 continue;
860 }
861 let _ = writeln!(
862 s,
863 "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
864 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
865 counts["resolves"].as_u64().unwrap_or(0),
866 counts["drifted"].as_u64().unwrap_or(0),
867 counts["recheck"].as_u64().unwrap_or(0),
868 counts["unresolvable"].as_u64().unwrap_or(0),
869 counts["unobserved"].as_u64().unwrap_or(0),
870 counts["dangling"].as_u64().unwrap_or(0),
871 counts["population"]
872 .as_str()
873 .unwrap_or("population not stated"),
874 );
875 }
876 }
877
878 if let Some(obj) = v.get("vital_signs").and_then(|x| x.as_object()) {
881 let mems: Vec<(&String, &serde_json::Value)> =
882 obj.iter().filter(|(k, _)| *k != "_item_cap").collect();
883 let _ = writeln!(s, "\n## Vital signs ({} mems)", mems.len());
884 for (mem, sig) in mems {
885 let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
886 let share = match sig["type_share_by_community"]["status"].as_str() {
887 Some("declared") => format!(
888 "last-resort type `{}` over {} communit{}",
889 sig["type_share_by_community"]["last_resort_type"]
890 .as_str()
891 .unwrap_or("?"),
892 count("type_share_by_community"),
893 if count("type_share_by_community") == 1 {
894 "y"
895 } else {
896 "ies"
897 }
898 ),
899 _ => "last-resort type not declared".to_string(),
900 };
901 let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
902 Some("enumerated") => format!(
903 "{} unclaimed source file(s)",
904 count("unclaimed_source_files")
905 ),
906 _ => "no bound source".to_string(),
907 };
908 let _ = writeln!(
909 s,
910 "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
911 entit{} in {} communit{}; {} empty declared section(s)",
912 count("contested_unowned_files"),
913 sig["zero_outgoing_entities"]["entities"]
914 .as_u64()
915 .unwrap_or(0),
916 if sig["zero_outgoing_entities"]["entities"]
917 .as_u64()
918 .unwrap_or(0)
919 == 1
920 {
921 "y"
922 } else {
923 "ies"
924 },
925 count("zero_outgoing_entities"),
926 if count("zero_outgoing_entities") == 1 {
927 "y"
928 } else {
929 "ies"
930 },
931 count("empty_declared_sections"),
932 );
933 }
934 }
935
936 if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
941 let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
942 for (mem, c) in obj {
943 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
944 let conf = |key: &str| {
945 c.get("conformance")
946 .and_then(|g| g.get(key))
947 .and_then(|x| x.as_u64())
948 .unwrap_or(0)
949 };
950 let gate = |key: &str| {
951 c.get("independence")
952 .and_then(|g| g.get(key))
953 .and_then(|e| e.get("count"))
954 .and_then(|x| x.as_u64())
955 .unwrap_or(0)
956 };
957 let _ = writeln!(
958 s,
959 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
960 check_stale {}; conformance: never_checked {}, \
961 checked_ok {}, check_failed {}, check_stale {}; \
962 independence: self_checked {}, \
963 confirmed_independent {}, unconfirmable {}",
964 count("never_checked"),
965 count("checked_ok"),
966 count("check_failed"),
967 count("check_stale"),
968 conf("never_checked"),
969 conf("checked_ok"),
970 conf("check_failed"),
971 conf("check_stale"),
972 gate("self_checked"),
973 gate("confirmed_independent"),
974 gate("unconfirmable"),
975 );
976 if let Some(foreign) = c.get("foreign_kinds").and_then(|f| f.as_object())
980 && !foreign.is_empty()
981 {
982 let listed: Vec<String> = foreign
983 .iter()
984 .map(|(k, n)| format!("{k} {}", n.as_u64().unwrap_or(0)))
985 .collect();
986 let _ = writeln!(s, " - foreign kinds: {}", listed.join(", "));
987 }
988 if let Some(findings) = c.get("findings").and_then(|f| f.as_object()) {
989 for (entity, f) in findings {
990 let code = f["finding"]["code"].as_str().unwrap_or("?");
991 let section = f["finding"]["section"]
992 .as_str()
993 .map(|x| format!(" [{x}]"))
994 .unwrap_or_default();
995 let message = f["finding"]["message"].as_str().unwrap_or("");
996 let _ = writeln!(
997 s,
998 " - finding on `{entity}` ({} {}): {code}{section} — {message}",
999 f["kind"].as_str().unwrap_or("verification"),
1000 f["verdict"].as_str().unwrap_or("?"),
1001 );
1002 }
1003 }
1004 }
1005 }
1006
1007 if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
1010 let total: usize = obj
1011 .values()
1012 .filter_map(|a| a.as_array().map(|a| a.len()))
1013 .sum();
1014 let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
1015 for (mem, findings) in obj {
1016 for f in findings.as_array().into_iter().flatten() {
1017 let _ = writeln!(
1018 s,
1019 "- `{mem}`: {} -[{}]-> {} ({})",
1020 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
1021 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
1022 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
1023 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
1024 );
1025 }
1026 }
1027 }
1028
1029 if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
1034 let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
1035 for q in arr {
1036 let _ = writeln!(
1037 s,
1038 "- `{}` [{}] {}",
1039 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
1040 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
1041 q.get("reason_message")
1042 .and_then(|x| x.as_str())
1043 .unwrap_or(""),
1044 );
1045 }
1046 }
1047
1048 if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
1049 && !arr.is_empty()
1050 {
1051 let _ = writeln!(s, "\n## Warnings ({})", arr.len());
1052 for w in arr {
1053 let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
1054 let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
1055 let _ = writeln!(s, "- [{code}] {msg}");
1056 }
1057 }
1058
1059 s
1060}
1061
1062fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
1066 use std::fmt::Write as _;
1067 let Some(map) = val.and_then(|x| x.as_object()) else {
1068 return;
1069 };
1070 if map.is_empty() {
1071 return;
1072 }
1073 let _ = writeln!(s, "- {title}:");
1074 for (k, n) in map {
1075 let label = if k.is_empty() {
1076 "(unpinned)"
1077 } else {
1078 k.as_str()
1079 };
1080 let _ = writeln!(s, " - {label}: {}", n.as_u64().unwrap_or(0));
1081 }
1082}
1083
1084fn summarize_health_item(item: &serde_json::Value) -> String {
1096 if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
1097 match item.get("title").and_then(|x| x.as_str()) {
1098 Some(t) if !t.is_empty() => format!("{id} — {t}"),
1099 _ => id.to_string(),
1100 }
1101 } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
1102 let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
1103 match item.get("kind").and_then(|x| x.as_str()) {
1104 Some(kind) => format!("[{kind}] {from} → {target}"),
1105 None => format!("{from} → {target}"),
1106 }
1107 } else {
1108 serde_json::to_string(item).unwrap_or_default()
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::render_health_markdown;
1115 use serde_json::json;
1116
1117 fn base_payload() -> serde_json::Value {
1118 json!({
1119 "summary": { "total_entities": 1 },
1120 "total_nodes": 1,
1121 })
1122 }
1123
1124 #[test]
1131 fn body_observations_render_their_code_and_fate_not_just_an_id() {
1132 let mut v = base_payload();
1133 v["body_observations"] = json!([{
1134 "id": "specs--alpha",
1135 "code": "ABSORBED_SECTION",
1136 "fate": "absorbed",
1137 "detail": { "heading": "Rogue", "note": "survives the next write" },
1138 }, {
1139 "id": "specs--beta",
1140 "code": "UNDECLARED_METADATA_KEY",
1141 "fate": "dropped",
1142 "detail": { "key": "reviewer", "note": "the next write drops it" },
1143 }]);
1144 let md = render_health_markdown(&v);
1145 assert!(md.contains("## Body observations (2)"), "{md}");
1146 assert!(
1147 md.contains(
1148 "- specs--alpha [ABSORBED_SECTION] `Rogue`: absorbed (survives the next write)"
1149 ),
1150 "{md}"
1151 );
1152 assert!(
1153 md.contains(
1154 "- specs--beta [UNDECLARED_METADATA_KEY] `reviewer`: dropped \
1155 (the next write drops it)"
1156 ),
1157 "{md}"
1158 );
1159 assert!(!render_health_markdown(&base_payload()).contains("Body observations"));
1161 }
1162
1163 #[test]
1170 fn render_health_markdown_names_the_dangling_condition() {
1171 let mut v = base_payload();
1172 v["dangling_links"] = json!([
1173 {
1174 "kind": "DANGLING_LINK_TARGET_MISSING",
1175 "from": "specs--a", "target_id": "specs--gone",
1176 "target_path": "gone", "section": "purpose",
1177 },
1178 {
1179 "kind": "DANGLING_RELATION_TARGET_MISSING",
1180 "from": "specs--b", "target_id": "specs--vanished",
1181 "target_path": "vanished", "section": null,
1182 },
1183 ]);
1184 let md = render_health_markdown(&v);
1185 assert!(
1186 md.contains("[DANGLING_LINK_TARGET_MISSING] specs--a → specs--gone"),
1187 "{md}"
1188 );
1189 assert!(
1190 md.contains("[DANGLING_RELATION_TARGET_MISSING] specs--b → specs--vanished"),
1191 "{md}"
1192 );
1193 assert!(
1195 !md.contains("- specs--a → specs--gone"),
1196 "the unprefixed form is what fused them: {md}"
1197 );
1198 }
1199
1200 #[test]
1207 fn render_health_markdown_covers_checks_derivations_and_quarantine() {
1208 let mut v = base_payload();
1210 v["checks"] = json!({
1211 "specs": {
1212 "never_checked": 2, "checked_ok": 1,
1213 "check_failed": 0, "check_stale": 0,
1214 "conformance": {
1215 "never_checked": 3, "checked_ok": 0,
1216 "check_failed": 0, "check_stale": 0,
1217 },
1218 "independence": {
1219 "self_checked": { "count": 0, "items": [] },
1220 "confirmed_independent": { "count": 0, "items": [] },
1221 "unconfirmable": { "count": 1, "items": ["specs--a"] },
1222 },
1223 }
1224 });
1225 v["stale_derivations"] = json!({
1226 "specs": [{
1227 "source": "specs--a", "rel_type": "DERIVES_FROM",
1228 "target": "specs--b", "state": "stale",
1229 "baseline": "aaa", "current": "bbb",
1230 }]
1231 });
1232 v["quarantined"] = json!([{
1233 "mem": "broken",
1234 "reason_code": "SCHEMA_NOT_FOUND",
1235 "reason_message": "no schema; repair via memstead mem set-schema",
1236 }]);
1237 let md = render_health_markdown(&v);
1238 assert!(md.contains("## Checks (1 mems)"), "{md}");
1239 assert!(
1240 md.contains(
1241 "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
1242 check_stale 0; conformance: never_checked 3, checked_ok 0, \
1243 check_failed 0, check_stale 0; independence: self_checked 0, \
1244 confirmed_independent 0, unconfirmable 1"
1245 ),
1246 "{md}"
1247 );
1248 assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
1249 assert!(
1250 md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
1251 "{md}"
1252 );
1253 assert!(md.contains("## Quarantined mems (1)"), "{md}");
1254 assert!(
1255 md.contains(
1256 "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
1257 ),
1258 "{md}"
1259 );
1260
1261 let mut empty = base_payload();
1263 empty["checks"] = json!({});
1264 empty["stale_derivations"] = json!({ "specs": [] });
1265 let md = render_health_markdown(&empty);
1266 assert!(md.contains("## Checks (0 mems)"), "{md}");
1267 assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
1268
1269 let base_md = render_health_markdown(&base_payload());
1272 for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
1273 assert!(
1274 !base_md.contains(heading),
1275 "absent key must render nothing: {base_md}"
1276 );
1277 }
1278 let appended = render_health_markdown(&v);
1279 assert!(
1280 appended.starts_with(&base_md),
1281 "sections append; the base output stays byte-identical"
1282 );
1283 }
1284}