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] memstead_base::EngineError),
73}
74
75pub fn compose_health(
80 engine: &mut memstead_base::Engine,
81 args: &HealthArgs,
82 drift_warnings: Vec<memstead_base::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<memstead_base::WarningHint> = drift_warnings;
93 warnings.extend(health.warnings.clone());
94 if requested_limit > HEALTH_LIMIT_MAX {
95 warnings.push(memstead_base::WarningHint::LimitClamped {
96 requested: requested_limit,
97 actual: HEALTH_LIMIT_MAX,
98 });
99 }
100
101 for key in include {
102 if !memstead_base::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
103 warnings.push(memstead_base::WarningHint::UnknownIncludeKey {
104 key: key.clone(),
105 allowed: memstead_base::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: &memstead_base::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<memstead_base::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<(memstead_base::EntityId, Vec<memstead_base::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) => memstead_base::graph::community::clusters_in_mem(
188 engine.store(),
189 engine.communities(),
190 v,
191 )
192 .len(),
193 None => engine.communities().count,
194 };
195
196 let (edge_count, edge_types) = {
199 if let Some(v) = vf {
200 let mut counts: HashMap<String, usize> = HashMap::new();
201 let mut total: usize = 0;
202 for id in engine.store().all_ids() {
203 let source_mem = engine.store().get(id).map(|e| e.mem.clone());
204 if let Some(source) = source_mem.as_deref()
205 && source != v
206 {
207 continue;
208 }
209 for edge in engine.store().outgoing(id) {
210 *counts.entry(edge.rel_type.clone()).or_insert(0) += 1;
211 total += 1;
212 }
213 }
214 let mut pairs: Vec<_> = counts.into_iter().collect();
215 pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
216 let arr: Vec<serde_json::Value> = pairs
217 .into_iter()
218 .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
219 .collect();
220 (total, arr)
221 } else {
222 let mut pairs: Vec<_> = stats.edge_types.iter().collect();
223 pairs.sort_by(|a, b| b.1.cmp(a.1));
224 let arr: Vec<serde_json::Value> = pairs
225 .into_iter()
226 .map(|(t, c)| serde_json::json!({"type": t, "count": c}))
227 .collect();
228 (stats.edge_count, arr)
229 }
230 };
231
232 let type_distribution: Vec<serde_json::Value> = {
233 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
234 for e in engine
235 .store()
236 .all_entities()
237 .filter(|e| !e.stub && in_mem(e))
238 {
239 *counts.entry(&e.entity_type).or_default() += 1;
240 }
241 let mut pairs: Vec<_> = counts.into_iter().collect();
242 pairs.sort_by_key(|p| std::cmp::Reverse(p.1));
243 pairs
244 .into_iter()
245 .map(|(s, c)| serde_json::json!({"type": s, "count": c}))
246 .collect()
247 };
248
249 let writable_mems: Vec<String> = {
250 let mut names: Vec<String> = engine
251 .mem_router()
252 .writable_mems()
253 .iter()
254 .cloned()
255 .collect();
256 names.sort();
257 names
258 };
259 let default_writable_mem: Option<String> = engine.default_writable_mem().map(|s| s.to_string());
264 let read_mems: Vec<String> = {
265 let writable_set: std::collections::HashSet<&String> =
266 engine.mem_router().writable_mems().iter().collect();
267 let mut names: Vec<String> = engine
268 .mem_router()
269 .visible_mems()
270 .iter()
271 .filter(|n| !writable_set.contains(*n))
272 .cloned()
273 .collect();
274 names.sort();
275 names
276 };
277
278 let mem_schemas: Vec<serde_json::Value> = {
291 let mut entries: Vec<serde_json::Value> = Vec::new();
292 let writable_set: std::collections::HashSet<&String> = writable_mems.iter().collect();
293 for name in writable_mems.iter().chain(read_mems.iter()) {
294 if let Some(v) = vf
295 && name != v
296 {
297 continue;
298 }
299 if let Some(m) = engine.mount(name) {
300 let schema_ref = m
305 .schema
306 .as_ref()
307 .map(|s| s.as_display())
308 .unwrap_or_default();
309 let mut entry = serde_json::json!({
310 "mem": name,
311 "schema": schema_ref,
312 "writable": writable_set.contains(name),
313 });
314 if let Some(target) = &m.migration_target {
318 entry["migration_target"] = serde_json::json!(target.as_display());
319 }
320 entries.push(entry);
321 }
322 }
323 entries
324 };
325
326 let orphans_by_schema = engine.orphans_by_schema(&orphan_ids);
337 let scope_mems: Vec<String> = match vf {
338 Some(v) => vec![v.to_string()],
339 None => writable_mems
340 .iter()
341 .chain(read_mems.iter())
342 .cloned()
343 .collect(),
344 };
345 let communities_by_schema = engine.communities_by_schema(&scope_mems);
346
347 let mut result = serde_json::json!({
348 "mem": mem_filter,
349 "verdict_coverage": memstead_base::ops::coverage::HEALTH_COVERAGE.wire_line(),
354 "summary": {
355 "total_entities": real_count,
356 "total_orphans": orphan_ids.len(),
357 "total_stubs": stub_pairs.len(),
358 "total_stale": health.stale_entities.iter().filter(|e| match vf {
359 Some(v) => engine.store().get(&e.id).map(|ent| ent.mem == v).unwrap_or(false),
360 None => true,
361 }).count(),
362 "total_missing_fields": health.missing_fields.iter().filter(|h| match vf {
363 Some(v) => engine.store().get(&h.id).map(|ent| ent.mem == v).unwrap_or(false),
364 None => true,
365 }).count(),
366 "total_communities": community_count,
367 "orphans_by_schema": orphans_by_schema,
368 "communities_by_schema": communities_by_schema,
369 },
370 "total_nodes": total_count,
371 "real_nodes": real_count,
372 "stub_nodes": stub_count,
373 "total_edges": edge_count,
374 "edge_types": edge_types,
375 "type_distribution": type_distribution,
376 "writable_mems": writable_mems,
377 "default_writable_mem": default_writable_mem,
378 "read_mems": read_mems,
379 "mem_schemas": mem_schemas,
380 });
381 let obj = result.as_object_mut().unwrap();
382 if !warnings.is_empty() {
383 obj.insert("warnings".into(), serde_json::json!(warnings));
384 }
385 if !health.quarantined.is_empty() {
388 obj.insert(
389 "quarantined".into(),
390 serde_json::to_value(&health.quarantined).unwrap_or_default(),
391 );
392 }
393 if !health.load_errors.is_empty() {
398 obj.insert(
399 "load_errors".into(),
400 serde_json::to_value(&health.load_errors).unwrap_or_default(),
401 );
402 }
403 if let Some(diag) = &health.boot_diagnosis {
404 obj.insert("boot_diagnosis".into(), diag.clone());
405 }
406 if !health.leaf_entities_by_type.is_empty() {
409 obj.insert(
410 "leaf_entities_by_type".into(),
411 serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
412 );
413 }
414
415 if include.iter().any(|s| s == "orphans") {
416 let orphans_list: Vec<serde_json::Value> = orphan_ids
417 .into_iter()
418 .map(|id| {
419 let title = engine
420 .get_entity(&id)
421 .map(|e| e.title.clone())
422 .unwrap_or_default();
423 serde_json::json!({"id": id.to_string(), "title": title})
424 })
425 .collect();
426 obj.insert("orphans".into(), serde_json::json!(orphans_list));
427 }
428 if include.iter().any(|s| s == "stubs") {
429 let stubs_list: Vec<serde_json::Value> = stub_pairs
430 .into_iter()
431 .map(|(id, refs)| {
432 serde_json::json!({
433 "id": id.to_string(),
434 "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
435 })
436 })
437 .collect();
438 obj.insert("stubs".into(), serde_json::json!(stubs_list));
439 }
440 if include.iter().any(|s| s == "most_connected") {
441 use memstead_base::graph::query::{Connectivity, cmp_by_dependency, connectivity_for};
442 let to_json = |c: Connectivity| {
447 let title = engine
448 .get_entity(&c.id)
449 .map(|e| e.title.clone())
450 .unwrap_or_default();
451 serde_json::json!({
452 "id": c.id.to_string(),
453 "title": title,
454 "total": c.total,
455 "incoming": c.incoming,
456 "outgoing": c.outgoing,
457 "typed_total": c.typed_total,
458 "typed_incoming": c.typed_incoming,
459 "typed_outgoing": c.typed_outgoing,
460 })
461 };
462 let connected: Vec<serde_json::Value> = if let Some(v) = vf {
463 let mut entries: Vec<Connectivity> = engine
469 .store()
470 .all_entities()
471 .filter(|e| !e.stub && e.mem == v)
472 .map(|e| connectivity_for(engine.store(), &e.id, |in_edge| in_edge.from.mem() == v))
473 .collect();
474 entries.sort_by(cmp_by_dependency);
475 entries.truncate(limit);
476 entries.into_iter().map(to_json).collect()
477 } else {
478 engine
479 .most_connected(limit)
480 .into_iter()
481 .map(to_json)
482 .collect()
483 };
484 obj.insert("most_connected".into(), serde_json::json!(connected));
485 }
486 if include.iter().any(|s| s == "missing_fields") {
487 let missing_fields: Vec<serde_json::Value> = health
488 .missing_fields
489 .iter()
490 .filter(|h| match vf {
491 Some(v) => engine
492 .store()
493 .get(&h.id)
494 .map(|e| e.mem == v)
495 .unwrap_or(false),
496 None => true,
497 })
498 .map(|h| {
499 let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
505 let issues: Vec<serde_json::Value> = h
506 .issues
507 .iter()
508 .map(|i| {
509 serde_json::json!({
510 "field": i.field,
511 "code": i.code,
512 "message": i.message,
513 })
514 })
515 .collect();
516 serde_json::json!({
517 "id": h.id.to_string(),
518 "title": h.title,
519 "missing": missing,
520 "issues": issues,
521 })
522 })
523 .collect();
524 obj.insert("missing_fields".into(), serde_json::json!(missing_fields));
525 }
526 if include.iter().any(|s| s == "stale") {
527 let stale: Vec<serde_json::Value> = health
528 .stale_entities
529 .iter()
530 .filter(|e| match vf {
531 Some(v) => engine
532 .store()
533 .get(&e.id)
534 .map(|ent| ent.mem == v)
535 .unwrap_or(false),
536 None => true,
537 })
538 .map(|e| {
539 serde_json::json!({
540 "id": e.id.to_string(),
541 "title": e.title,
542 "days_since_modified": e.days_since_modified,
543 })
544 })
545 .collect();
546 obj.insert("stale".into(), serde_json::json!(stale));
547 }
548 if include.iter().any(|s| s == "dangling_links") {
549 let dangling = memstead_base::ops::health::collect_dangling_links(engine.store(), vf);
550 let arr: Vec<serde_json::Value> = dangling
551 .into_iter()
552 .map(|dl| serde_json::to_value(&dl).unwrap())
553 .collect();
554 obj.insert("dangling_links".into(), serde_json::json!(arr));
555 }
556 if include.iter().any(|s| s == "anchors") {
557 obj.insert(
558 "anchors".into(),
559 memstead_base::ops::health::health_anchors_axis(engine),
560 );
561 }
562 if include.iter().any(|s| s == "stale_derivations") {
563 obj.insert(
564 "stale_derivations".into(),
565 memstead_base::ops::health::health_stale_derivations_axis(engine, args.mem),
566 );
567 }
568 if include.iter().any(|s| s == "checks") {
569 obj.insert(
570 "checks".into(),
571 memstead_base::ops::health::health_checks_axis(engine, args.mem),
572 );
573 }
574 if include.iter().any(|s| s == "signals") {
575 obj.insert("signals".into(), engine.health_signals_axis(args.mem));
579 }
580 if include.iter().any(|s| s == "labelling") {
581 obj.insert("labelling".into(), engine.health_labelling_axis(args.mem));
585 }
586 if include.iter().any(|s| s == "open_questions") {
587 obj.insert(
588 "open_questions".into(),
589 memstead_base::ops::health::health_open_questions_axis(engine, args.mem),
590 );
591 }
592 if include.iter().any(|s| s == "friction") {
593 let summary = match engine.workspace_root() {
599 Some(root) => memstead_base::friction::FrictionLedger::for_workspace(root).summarize(),
600 None => serde_json::json!({
601 "total": 0,
602 "by_code": {},
603 "by_verb": {},
604 "recent_24h": { "total": 0, "by_code": {} },
605 "ledger_bytes": 0,
606 }),
607 };
608 obj.insert("friction".into(), summary);
609 }
610 if include.iter().any(|s| s == "missing_required_outgoing") {
611 let reports = engine.missing_required_outgoing(vf);
612 let arr: Vec<serde_json::Value> = reports
613 .into_iter()
614 .map(|r| serde_json::to_value(&r).unwrap())
615 .collect();
616 obj.insert("missing_required_outgoing".into(), serde_json::json!(arr));
617 }
618 if include.iter().any(|s| s == "constraints") {
619 let reports = engine.constraint_findings(vf);
620 let arr: Vec<serde_json::Value> = reports
621 .into_iter()
622 .map(|r| serde_json::to_value(&r).unwrap())
623 .collect();
624 obj.insert("constraints".into(), serde_json::json!(arr));
625 let defects = engine.schema_format_defects();
626 if !defects.is_empty() {
627 obj.insert(
628 "schema_format_defects".into(),
629 serde_json::to_value(&defects).unwrap(),
630 );
631 }
632 }
633 if include.iter().any(|s| s == "tags") {
634 let (distribution, folded, untagged) =
635 memstead_base::ops::health::collect_tag_distribution(engine.store(), vf, limit);
636 obj.insert(
637 "tag_distribution".into(),
638 serde_json::to_value(&distribution).unwrap(),
639 );
640 obj.insert(
641 "tag_distribution_folded".into(),
642 serde_json::to_value(&folded).unwrap(),
643 );
644 obj.insert(
645 "untagged_entities".into(),
646 serde_json::to_value(&untagged).unwrap(),
647 );
648 }
649 let wants_conformance = include
655 .iter()
656 .any(|s| s == "conformance" || s == "integrity");
657 if wants_conformance {
658 let wants_consistency = include.iter().any(|s| s == "integrity");
659 let target: Option<memstead_schema::SchemaRef> = match args.target_schema {
660 None => None,
661 Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
662 Ok(r) => Some(r),
663 Err(reason) => {
664 return Err(ComposeHealthError::InvalidTargetSchema {
665 raw: raw.to_string(),
666 reason,
667 });
668 }
669 },
670 };
671 let scan_mems: Vec<String> = match vf {
672 Some(v) => vec![v.to_string()],
673 None => {
674 let mut all = writable_mems.clone();
675 all.sort();
676 all
677 }
678 };
679 let mut findings = Vec::new();
680 let mut observations = Vec::new();
681 for v in &scan_mems {
682 findings.extend(engine.conformance_findings(v, target.as_ref())?);
683 observations.extend(engine.body_observations(v, target.as_ref())?);
687 if wants_consistency {
688 findings.extend(engine.consistency_findings(v)?);
689 }
690 }
691 obj.insert("findings".into(), serde_json::to_value(&findings).unwrap());
692 obj.insert(
693 "body_observations".into(),
694 serde_json::to_value(&observations).unwrap(),
695 );
696 }
697
698 if include.iter().any(|s| s == "ledger") {
705 obj.insert(
706 "ledger".into(),
707 serde_json::to_value(engine.ledger_reconciliation()).unwrap_or_default(),
708 );
709 }
710
711 if args.include_config || include.iter().any(|s| s == "config") {
720 let entries = memstead_base::ops::health::config_projection(
721 engine,
722 &writable_mems,
723 config.mutations.clone(),
724 config.plugin.clone(),
725 );
726 for (k, v) in entries {
727 obj.insert(k, v);
728 }
729 }
730
731 Ok(result)
732}
733
734pub fn render_health_markdown(v: &serde_json::Value) -> String {
742 use std::fmt::Write as _;
743 let mut s = String::new();
744 let _ = writeln!(s, "# Graph health");
745 if let Some(mem) = v.get("mem").and_then(|x| x.as_str()) {
746 let _ = writeln!(s, "\nMem filter: `{mem}`");
747 }
748
749 if let Some(sum) = v.get("summary").and_then(|x| x.as_object()) {
750 let _ = writeln!(s, "\n## Summary");
751 for key in [
752 "total_entities",
753 "total_orphans",
754 "total_stubs",
755 "total_stale",
756 "total_missing_fields",
757 "total_communities",
758 ] {
759 if let Some(n) = sum.get(key).and_then(|x| x.as_u64()) {
760 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
761 }
762 }
763 render_count_map(&mut s, sum.get("orphans_by_schema"), "Orphans by schema");
764 render_count_map(
765 &mut s,
766 sum.get("communities_by_schema"),
767 "Communities by schema",
768 );
769 }
770
771 for key in ["total_nodes", "real_nodes", "stub_nodes", "total_edges"] {
772 if let Some(n) = v.get(key).and_then(|x| x.as_u64()) {
773 let _ = writeln!(s, "- {}: {n}", key.replace('_', " "));
774 }
775 }
776
777 for (key, title) in [
779 ("orphans", "Orphans"),
780 ("stubs", "Stubs"),
781 ("most_connected", "Most connected"),
782 ("missing_fields", "Missing fields"),
783 ("stale", "Stale"),
784 ("dangling_links", "Dangling links"),
785 ("missing_required_outgoing", "Missing required outgoing"),
786 ("constraints", "Constraint violations"),
787 ("findings", "Findings"),
788 ] {
789 if let Some(arr) = v.get(key).and_then(|x| x.as_array()) {
790 let _ = writeln!(s, "\n## {title} ({})", arr.len());
791 for item in arr {
792 let _ = writeln!(s, "- {}", summarize_health_item(item));
793 }
794 }
795 }
796
797 if let Some(arr) = v.get("body_observations").and_then(|x| x.as_array()) {
802 let _ = writeln!(s, "\n## Body observations ({})", arr.len());
803 for item in arr {
804 let detail = &item["detail"];
805 let subject = detail
806 .get("heading")
807 .or_else(|| detail.get("key"))
808 .and_then(|x| x.as_str())
809 .unwrap_or("");
810 let _ = writeln!(
811 s,
812 "- {} [{}] `{subject}`: {} ({})",
813 item["id"].as_str().unwrap_or(""),
814 item["code"].as_str().unwrap_or(""),
815 item["fate"].as_str().unwrap_or(""),
816 detail
817 .get("note")
818 .and_then(|x| x.as_str())
819 .unwrap_or("no note"),
820 );
821 }
822 }
823
824 if let Some(obj) = v.get("anchors").and_then(|x| x.as_object()) {
830 let _ = writeln!(s, "\n## Anchors ({} mems)", obj.len());
831 for (mem, counts) in obj {
832 let _ = writeln!(
833 s,
834 "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable (artifact gone) \
835 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
836 counts["resolved"].as_u64().unwrap_or(0),
837 counts["drifted"].as_u64().unwrap_or(0),
838 counts["recheck"].as_u64().unwrap_or(0),
839 counts["unresolvable"].as_u64().unwrap_or(0),
840 counts["unobserved"].as_u64().unwrap_or(0),
841 counts["dangling"].as_u64().unwrap_or(0),
842 counts["population"]
843 .as_str()
844 .unwrap_or("population not stated"),
845 );
846 }
847 }
848
849 if let Some(obj) = v.get("checks").and_then(|x| x.as_object()) {
854 let _ = writeln!(s, "\n## Checks ({} mems)", obj.len());
855 for (mem, c) in obj {
856 let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
857 let conf = |key: &str| {
858 c.get("conformance")
859 .and_then(|g| g.get(key))
860 .and_then(|x| x.as_u64())
861 .unwrap_or(0)
862 };
863 let gate = |key: &str| {
864 c.get("independence")
865 .and_then(|g| g.get(key))
866 .and_then(|e| e.get("count"))
867 .and_then(|x| x.as_u64())
868 .unwrap_or(0)
869 };
870 let _ = writeln!(
871 s,
872 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
873 check_stale {}; conformance: never_checked {}, \
874 checked_ok {}, check_failed {}, check_stale {}; \
875 independence: self_checked {}, \
876 confirmed_independent {}, unconfirmable {}",
877 count("never_checked"),
878 count("checked_ok"),
879 count("check_failed"),
880 count("check_stale"),
881 conf("never_checked"),
882 conf("checked_ok"),
883 conf("check_failed"),
884 conf("check_stale"),
885 gate("self_checked"),
886 gate("confirmed_independent"),
887 gate("unconfirmable"),
888 );
889 }
890 }
891
892 if let Some(obj) = v.get("stale_derivations").and_then(|x| x.as_object()) {
895 let total: usize = obj
896 .values()
897 .filter_map(|a| a.as_array().map(|a| a.len()))
898 .sum();
899 let _ = writeln!(s, "\n## Stale derivations ({total} findings)");
900 for (mem, findings) in obj {
901 for f in findings.as_array().into_iter().flatten() {
902 let _ = writeln!(
903 s,
904 "- `{mem}`: {} -[{}]-> {} ({})",
905 f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
906 f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
907 f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
908 f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
909 );
910 }
911 }
912 }
913
914 if let Some(arr) = v.get("quarantined").and_then(|x| x.as_array()) {
919 let _ = writeln!(s, "\n## Quarantined mems ({})", arr.len());
920 for q in arr {
921 let _ = writeln!(
922 s,
923 "- `{}` [{}] {}",
924 q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
925 q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
926 q.get("reason_message")
927 .and_then(|x| x.as_str())
928 .unwrap_or(""),
929 );
930 }
931 }
932
933 if let Some(arr) = v.get("warnings").and_then(|x| x.as_array())
934 && !arr.is_empty()
935 {
936 let _ = writeln!(s, "\n## Warnings ({})", arr.len());
937 for w in arr {
938 let code = w.get("code").and_then(|x| x.as_str()).unwrap_or("");
939 let msg = w.get("message").and_then(|x| x.as_str()).unwrap_or("");
940 let _ = writeln!(s, "- [{code}] {msg}");
941 }
942 }
943
944 s
945}
946
947fn render_count_map(s: &mut String, val: Option<&serde_json::Value>, title: &str) {
951 use std::fmt::Write as _;
952 let Some(map) = val.and_then(|x| x.as_object()) else {
953 return;
954 };
955 if map.is_empty() {
956 return;
957 }
958 let _ = writeln!(s, "- {title}:");
959 for (k, n) in map {
960 let label = if k.is_empty() {
961 "(unpinned)"
962 } else {
963 k.as_str()
964 };
965 let _ = writeln!(s, " - {label}: {}", n.as_u64().unwrap_or(0));
966 }
967}
968
969fn summarize_health_item(item: &serde_json::Value) -> String {
981 if let Some(id) = item.get("id").and_then(|x| x.as_str()) {
982 match item.get("title").and_then(|x| x.as_str()) {
983 Some(t) if !t.is_empty() => format!("{id} — {t}"),
984 _ => id.to_string(),
985 }
986 } else if let Some(from) = item.get("from").and_then(|x| x.as_str()) {
987 let target = item.get("target_id").and_then(|x| x.as_str()).unwrap_or("");
988 match item.get("kind").and_then(|x| x.as_str()) {
989 Some(kind) => format!("[{kind}] {from} → {target}"),
990 None => format!("{from} → {target}"),
991 }
992 } else {
993 serde_json::to_string(item).unwrap_or_default()
994 }
995}
996
997#[cfg(test)]
998mod tests {
999 use super::render_health_markdown;
1000 use serde_json::json;
1001
1002 fn base_payload() -> serde_json::Value {
1003 json!({
1004 "summary": { "total_entities": 1 },
1005 "total_nodes": 1,
1006 })
1007 }
1008
1009 #[test]
1016 fn body_observations_render_their_code_and_fate_not_just_an_id() {
1017 let mut v = base_payload();
1018 v["body_observations"] = json!([{
1019 "id": "specs--alpha",
1020 "code": "ABSORBED_SECTION",
1021 "fate": "absorbed",
1022 "detail": { "heading": "Rogue", "note": "survives the next write" },
1023 }, {
1024 "id": "specs--beta",
1025 "code": "UNDECLARED_METADATA_KEY",
1026 "fate": "dropped",
1027 "detail": { "key": "reviewer", "note": "the next write drops it" },
1028 }]);
1029 let md = render_health_markdown(&v);
1030 assert!(md.contains("## Body observations (2)"), "{md}");
1031 assert!(
1032 md.contains(
1033 "- specs--alpha [ABSORBED_SECTION] `Rogue`: absorbed (survives the next write)"
1034 ),
1035 "{md}"
1036 );
1037 assert!(
1038 md.contains(
1039 "- specs--beta [UNDECLARED_METADATA_KEY] `reviewer`: dropped \
1040 (the next write drops it)"
1041 ),
1042 "{md}"
1043 );
1044 assert!(!render_health_markdown(&base_payload()).contains("Body observations"));
1046 }
1047
1048 #[test]
1055 fn render_health_markdown_names_the_dangling_condition() {
1056 let mut v = base_payload();
1057 v["dangling_links"] = json!([
1058 {
1059 "kind": "DANGLING_LINK_TARGET_MISSING",
1060 "from": "specs--a", "target_id": "specs--gone",
1061 "target_path": "gone", "section": "purpose",
1062 },
1063 {
1064 "kind": "DANGLING_RELATION_TARGET_MISSING",
1065 "from": "specs--b", "target_id": "specs--vanished",
1066 "target_path": "vanished", "section": null,
1067 },
1068 ]);
1069 let md = render_health_markdown(&v);
1070 assert!(
1071 md.contains("[DANGLING_LINK_TARGET_MISSING] specs--a → specs--gone"),
1072 "{md}"
1073 );
1074 assert!(
1075 md.contains("[DANGLING_RELATION_TARGET_MISSING] specs--b → specs--vanished"),
1076 "{md}"
1077 );
1078 assert!(
1080 !md.contains("- specs--a → specs--gone"),
1081 "the unprefixed form is what fused them: {md}"
1082 );
1083 }
1084
1085 #[test]
1092 fn render_health_markdown_covers_checks_derivations_and_quarantine() {
1093 let mut v = base_payload();
1095 v["checks"] = json!({
1096 "specs": {
1097 "never_checked": 2, "checked_ok": 1,
1098 "check_failed": 0, "check_stale": 0,
1099 "conformance": {
1100 "never_checked": 3, "checked_ok": 0,
1101 "check_failed": 0, "check_stale": 0,
1102 },
1103 "independence": {
1104 "self_checked": { "count": 0, "items": [] },
1105 "confirmed_independent": { "count": 0, "items": [] },
1106 "unconfirmable": { "count": 1, "items": ["specs--a"] },
1107 },
1108 }
1109 });
1110 v["stale_derivations"] = json!({
1111 "specs": [{
1112 "source": "specs--a", "rel_type": "DERIVES_FROM",
1113 "target": "specs--b", "state": "stale",
1114 "baseline": "aaa", "current": "bbb",
1115 }]
1116 });
1117 v["quarantined"] = json!([{
1118 "mem": "broken",
1119 "reason_code": "SCHEMA_NOT_FOUND",
1120 "reason_message": "no schema; repair via memstead mem set-schema",
1121 }]);
1122 let md = render_health_markdown(&v);
1123 assert!(md.contains("## Checks (1 mems)"), "{md}");
1124 assert!(
1125 md.contains(
1126 "- `specs`: never_checked 2, checked_ok 1, check_failed 0, \
1127 check_stale 0; conformance: never_checked 3, checked_ok 0, \
1128 check_failed 0, check_stale 0; independence: self_checked 0, \
1129 confirmed_independent 0, unconfirmable 1"
1130 ),
1131 "{md}"
1132 );
1133 assert!(md.contains("## Stale derivations (1 findings)"), "{md}");
1134 assert!(
1135 md.contains("- `specs`: specs--a -[DERIVES_FROM]-> specs--b (stale)"),
1136 "{md}"
1137 );
1138 assert!(md.contains("## Quarantined mems (1)"), "{md}");
1139 assert!(
1140 md.contains(
1141 "- `broken` [SCHEMA_NOT_FOUND] no schema; repair via memstead mem set-schema"
1142 ),
1143 "{md}"
1144 );
1145
1146 let mut empty = base_payload();
1148 empty["checks"] = json!({});
1149 empty["stale_derivations"] = json!({ "specs": [] });
1150 let md = render_health_markdown(&empty);
1151 assert!(md.contains("## Checks (0 mems)"), "{md}");
1152 assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
1153
1154 let base_md = render_health_markdown(&base_payload());
1157 for heading in ["## Checks", "## Stale derivations", "## Quarantined mems"] {
1158 assert!(
1159 !base_md.contains(heading),
1160 "absent key must render nothing: {base_md}"
1161 );
1162 }
1163 let appended = render_health_markdown(&v);
1164 assert!(
1165 appended.starts_with(&base_md),
1166 "sections append; the base output stays byte-identical"
1167 );
1168 }
1169}