1use clap::Parser;
10use memstead_base::ops::health_compose::{
11 ComposeHealthError, HealthArgs, HealthConfig, compose_health,
12};
13use serde_json::Value;
14
15use crate::output::{ExitKind, print_json, print_markdown};
16use crate::setup::CliContext;
17
18#[derive(Parser, Debug)]
22pub struct Args {
23 #[arg(long)]
32 pub mem: Option<String>,
33
34 #[arg(long, value_delimiter = ',')]
84 pub include: Vec<String>,
85
86 #[arg(long)]
89 pub target_schema: Option<String>,
90
91 #[arg(long, default_value_t = 10)]
93 pub limit: usize,
94
95 #[arg(long)]
117 pub strict: bool,
118}
119
120pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
121 let mut cli_engine = ctx.cli_engine()?;
122 let engine = cli_engine.base_mut();
123 engine.ensure_mems_loaded(None);
126 let drift_warnings = engine.reload_if_stale(args.mem.as_deref());
127 let _ = engine.take_mem_changed_notices();
128
129 let (mutations, plugin) =
130 memstead_base::ops::health::config_projection_from_settings(engine.settings());
131 let config = HealthConfig { mutations, plugin };
132 let health_args = HealthArgs {
133 mem: args.mem.as_deref(),
134 include: &args.include,
135 limit: Some(args.limit),
136 target_schema: args.target_schema.as_deref(),
137 include_config: false,
138 };
139
140 let result = match compose_health(engine, &health_args, drift_warnings, &config) {
141 Ok(v) => v,
142 Err(ComposeHealthError::MemQuarantined(name)) => {
143 return Err(crate::CliError::from_engine_op(engine.unknown_mem_error(&name)).into());
144 }
145 Err(ComposeHealthError::UnknownMem {
146 name,
147 writable_mems,
148 }) => {
149 return Err(crate::CliError {
150 code: "UNKNOWN_MEM",
151 kind: ExitKind::NotFound,
152 message: format!(
153 "unknown mem: \"{name}\". Writable mems: [{}]",
154 writable_mems.join(", ")
155 ),
156 details: Some(serde_json::json!({
157 "name": name,
158 "writable_mems": writable_mems,
159 })),
160 }
161 .into());
162 }
163 Err(ComposeHealthError::InvalidTargetSchema { raw, reason }) => {
164 return Err(crate::CliError::new(
165 ExitKind::Validation,
166 "INVALID_INPUT",
167 format!("invalid target_schema {raw:?}: {reason}"),
168 )
169 .into());
170 }
171 Err(ComposeHealthError::Engine(e)) => {
172 return Err(crate::CliError::from_engine_op(e).into());
173 }
174 };
175
176 let strict_violations = strict_violations(&result, &args.include);
177
178 if ctx.json {
179 print_json(&result)?;
180 return strict_exit(args.strict, &strict_violations);
181 }
182
183 print_markdown(&render_markdown(&result, args.mem.as_deref()));
184 strict_exit(args.strict, &strict_violations)
185}
186
187fn strict_violations(v: &Value, include: &[String]) -> Vec<(&'static str, usize)> {
192 let has = |key: &str| include.iter().any(|s| s == key);
193 let arr_len = |key: &str| v.get(key).and_then(Value::as_array).map_or(0, Vec::len);
194 let mut out: Vec<(&'static str, usize)> = Vec::new();
195 fn push(out: &mut Vec<(&'static str, usize)>, label: &'static str, n: usize) {
196 if n > 0 {
197 out.push((label, n));
198 }
199 }
200
201 if has("missing_required_outgoing") {
202 push(
203 &mut out,
204 "missing_required_outgoing",
205 arr_len("missing_required_outgoing"),
206 );
207 }
208 if has("constraints") {
209 push(&mut out, "constraints", arr_len("constraints"));
210 push(
211 &mut out,
212 "schema_format_defects",
213 arr_len("schema_format_defects"),
214 );
215 }
216 if has("integrity") {
217 let findings = v.get("findings").and_then(Value::as_array);
218 let count_code = |pred: &dyn Fn(&str) -> bool| {
219 findings.map_or(0, |f| {
220 f.iter()
221 .filter(|x| x["code"].as_str().is_some_and(pred))
222 .count()
223 })
224 };
225 push(
226 &mut out,
227 "dangling_links",
228 count_code(&|c| memstead_base::ops::DanglingLinkKind::ALL_CODES.contains(&c)),
229 );
230 push(
231 &mut out,
232 "unresolved_stubs",
233 count_code(&|c| c == "UNRESOLVED_STUB"),
234 );
235 push(
236 &mut out,
237 "ungranted_cross_mem_edges",
238 count_code(&|c| c == "CROSS_MEM_EDGE_UNGRANTED"),
239 );
240 push(
241 &mut out,
242 "anchors_sidecar_unreadable",
243 count_code(&|c| c == "ANCHORS_SIDECAR_UNREADABLE"),
244 );
245 }
246 if let Some(warn) = v["signals"]["counts"]["warn"].as_u64() {
247 push(&mut out, "signals", warn as usize);
248 }
249 if let Some(mems) = v.get("anchors").and_then(Value::as_object)
250 && !out.iter().any(|(k, _)| *k == "anchors_sidecar_unreadable")
251 {
252 let unreadable = mems
253 .values()
254 .filter(|m| m.get("condition").is_some_and(|c| !c.is_null()))
255 .count();
256 push(&mut out, "anchors_sidecar_unreadable", unreadable);
257 }
258
259 let warnings = v.get("warnings").and_then(Value::as_array);
260 let count_warning = |pred: &dyn Fn(&str) -> bool| {
261 warnings.map_or(0, |w| {
262 w.iter()
263 .filter(|x| x["code"].as_str().is_some_and(pred))
264 .count()
265 })
266 };
267 push(
268 &mut out,
269 "schema_authoring_drift",
270 count_warning(&|c| {
271 matches!(
272 c,
273 "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
274 )
275 }),
276 );
277 for (label, code) in [
278 ("schema_pin_mismatch", "SCHEMA_PIN_MISMATCH"),
279 ("schema_unstamped_source_rot", "SCHEMA_UNSTAMPED_SOURCE_ROT"),
280 ("mount_unbacked", "MOUNT_UNBACKED"),
281 ] {
282 push(&mut out, label, count_warning(&|c| c == code));
283 }
284 out
285}
286
287fn s<'a>(v: &'a Value, key: &str) -> &'a str {
288 v[key].as_str().unwrap_or("")
289}
290
291fn n(v: &Value, key: &str) -> u64 {
292 v[key].as_u64().unwrap_or(0)
293}
294
295fn strs(v: &Value) -> Vec<&str> {
296 v.as_array()
297 .map(|a| a.iter().filter_map(Value::as_str).collect())
298 .unwrap_or_default()
299}
300
301fn counts_desc(map: &serde_json::Map<String, Value>) -> Vec<(&String, u64)> {
303 let mut entries: Vec<(&String, u64)> = map
304 .iter()
305 .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
306 .collect();
307 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
308 entries
309}
310
311fn render_markdown(v: &Value, mem: Option<&str>) -> String {
314 let mut lines: Vec<String> = Vec::new();
315 lines.push("# Graph health".to_string());
316 lines.push(String::new());
317 if let Some(cov) = v["verdict_coverage"].as_str() {
326 lines.push(format!("**Verdict coverage:** {cov}"));
327 lines.push(String::new());
328 }
329 if let Some(m) = mem {
330 lines.push(format!("**Mem filter:** `{m}`"));
331 lines.push(String::new());
332 }
333 let summary = &v["summary"];
334 lines.push(format!("- Entities: {}", n(summary, "total_entities")));
335 match summary["orphans_by_schema"].as_object() {
336 Some(by) if by.len() > 1 => {
337 let listed: Vec<String> = by
338 .iter()
339 .map(|(schema, count)| {
340 format!(
341 "{}: {}",
342 if schema.is_empty() {
343 "(unpinned)"
344 } else {
345 schema
346 },
347 count.as_u64().unwrap_or(0)
348 )
349 })
350 .collect();
351 lines.push(format!(
352 "- Orphans: {} ({})",
353 n(summary, "total_orphans"),
354 listed.join(", ")
355 ));
356 }
357 _ => lines.push(format!("- Orphans: {}", n(summary, "total_orphans"))),
358 }
359 lines.push(format!("- Stubs: {}", n(summary, "total_stubs")));
360 lines.push(format!("- Stale: {}", n(summary, "total_stale")));
361 lines.push(format!(
362 "- Missing fields: {}",
363 n(summary, "total_missing_fields")
364 ));
365 lines.push(format!(
366 "- Communities: {}",
367 n(summary, "total_communities")
368 ));
369 lines.push(String::new());
370
371 if let Some(items) = v.get("orphans").and_then(Value::as_array) {
372 lines.push("## Orphans".to_string());
373 for item in items {
374 lines.push(format!("- {} — {}", s(item, "id"), s(item, "title")));
375 }
376 lines.push(String::new());
377 }
378 if let Some(items) = v.get("stubs").and_then(Value::as_array) {
379 lines.push("## Stubs".to_string());
380 for item in items {
381 lines.push(format!("- {}", s(item, "id")));
382 }
383 lines.push(String::new());
384 }
385 if let Some(items) = v.get("most_connected").and_then(Value::as_array) {
386 lines.push("## Most connected".to_string());
387 lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
388 for item in items {
389 lines.push(format!(
390 "- {} — {} (typed {}, total {}, in {}, out {})",
391 s(item, "id"),
392 s(item, "title"),
393 n(item, "typed_total"),
394 n(item, "total"),
395 n(item, "incoming"),
396 n(item, "outgoing"),
397 ));
398 }
399 lines.push(String::new());
400 }
401 if let Some(items) = v.get("missing_fields").and_then(Value::as_array) {
402 lines.push("## Missing fields".to_string());
403 for item in items {
404 let labels: Vec<String> = match item["issues"].as_array() {
405 Some(issues) if !issues.is_empty() => issues
406 .iter()
407 .map(|i| {
408 format!(
409 "{} ({})",
410 s(i, "field"),
411 i["code"].as_str().unwrap_or("MISSING")
412 )
413 })
414 .collect(),
415 _ => strs(&item["missing"])
416 .into_iter()
417 .map(str::to_string)
418 .collect(),
419 };
420 lines.push(format!(
421 "- {} — {} (issues: {})",
422 s(item, "id"),
423 s(item, "title"),
424 labels.join(", ")
425 ));
426 }
427 lines.push(String::new());
428 }
429 if let Some(items) = v.get("stale").and_then(Value::as_array) {
430 lines.push("## Stale entities".to_string());
431 for item in items {
432 lines.push(stale_line(item));
433 }
434 lines.push(String::new());
435 }
436 if let Some(items) = v.get("anchor_fresh").and_then(Value::as_array) {
437 lines.push("## Fresh by anchor clock".to_string());
438 for item in items {
439 lines.push(stale_line(item));
440 }
441 lines.push(String::new());
442 }
443 if let Some(items) = v.get("missing_required_outgoing").and_then(Value::as_array) {
444 lines.push("## Missing required outgoing".to_string());
445 for item in items {
446 let blocks: Vec<String> = item["missing"]
447 .as_array()
448 .map(|arr| {
449 arr.iter()
450 .map(|b| {
451 format!(
452 "[{}] {}",
453 strs(&b["relationships"]).join(", "),
454 s(b, "cardinality")
455 )
456 })
457 .collect()
458 })
459 .unwrap_or_default();
460 lines.push(format!(
461 "- {} — {} (missing: {})",
462 s(item, "id"),
463 s(item, "title"),
464 blocks.join("; ")
465 ));
466 }
467 lines.push(String::new());
468 }
469 if let Some(items) = v.get("findings").and_then(Value::as_array) {
470 let (conformance, consistency): (Vec<&Value>, Vec<&Value>) = items
475 .iter()
476 .partition(|item| item["axis"].as_str() != Some("consistency"));
477 let mut groups = vec![("Conformance", conformance)];
478 if !consistency.is_empty() {
479 groups.push(("Consistency", consistency));
480 }
481 for (label, rows) in groups {
482 lines.push(format!("## {label} findings ({})", rows.len()));
483 if rows.is_empty() {
484 lines.push("- none".to_string());
485 }
486 for item in rows {
487 let mut line = format!(
488 "- [{}] {} (axis {})",
489 item["code"].as_str().unwrap_or("?"),
490 s(item, "id"),
491 item["axis"].as_str().unwrap_or("?"),
492 );
493 for key in ["field", "heading", "section"] {
494 if let Some(val) = item["detail"][key].as_str() {
495 line.push_str(&format!(" — {key} `{val}`"));
496 }
497 }
498 lines.push(line);
499 }
500 lines.push(String::new());
501 }
502 }
503 if let Some(items) = v.get("body_observations").and_then(Value::as_array)
504 && !items.is_empty()
505 {
506 lines.push(format!("## Body observations ({})", items.len()));
507 for item in items {
508 let mut line = format!(
509 "- [{}] {} — {}",
510 item["code"].as_str().unwrap_or("?"),
511 s(item, "id"),
512 item["fate"].as_str().unwrap_or("?"),
513 );
514 for key in ["heading", "key"] {
515 if let Some(val) = item["detail"][key].as_str() {
516 line.push_str(&format!(", {key} `{val}`"));
517 }
518 }
519 lines.push(line);
520 }
521 lines.push(String::new());
522 }
523 if let Some(items) = v.get("constraints").and_then(Value::as_array) {
524 lines.push(format!("## Constraint violations ({})", items.len()));
525 if items.is_empty() {
526 lines.push("- none".to_string());
527 }
528 for item in items {
529 let mut kinds: Vec<String> = item["violations"]
530 .as_array()
531 .map(|a| {
532 a.iter()
533 .filter_map(|x| x["kind"].as_str())
534 .map(str::to_string)
535 .collect()
536 })
537 .unwrap_or_default();
538 if item["format_violations"]
539 .as_array()
540 .is_some_and(|a| !a.is_empty())
541 {
542 kinds.push("section_format".to_string());
543 }
544 lines.push(format!(
545 "- {} — {} ({})",
546 s(item, "id"),
547 s(item, "title"),
548 kinds.join(", "),
549 ));
550 }
551 lines.push(String::new());
552 }
553 if let Some(items) = v.get("schema_format_defects").and_then(Value::as_array) {
554 lines.push(format!("## Schema format defects ({})", items.len()));
555 for item in items {
556 lines.push(format!("- {item}"));
557 }
558 lines.push(String::new());
559 }
560 if let Some(items) = v.get("dangling_links").and_then(Value::as_array) {
561 lines.push("## Dangling links".to_string());
562 for item in items {
563 lines.push(format!(
564 "- [{}] {} → {}{}",
565 item["kind"].as_str().unwrap_or("?"),
566 s(item, "from"),
567 s(item, "target_id"),
568 item["section"]
569 .as_str()
570 .map(|sec| format!(" (in `{sec}`)"))
571 .unwrap_or_default(),
572 ));
573 }
574 lines.push(String::new());
575 }
576 if let Some(items) = v.get("tag_distribution").and_then(Value::as_array) {
577 lines.push("## Tags".to_string());
578 for item in items {
579 lines.push(format!("- {} ({})", s(item, "tag"), n(item, "count")));
580 }
581 lines.push(String::new());
582 }
583 if let Some(items) = v.get("warnings").and_then(Value::as_array) {
584 lines.push("## Warnings".to_string());
585 for w in items {
586 lines.push(format!("- {} — {}", s(w, "code"), s(w, "message")));
587 }
588 lines.push(String::new());
589 }
590 if let Some(u) = v.get("untagged_entities") {
591 lines.push("## Untagged".to_string());
592 lines.push(format!("- Total: {}", n(u, "total")));
593 if let Some(by_type) = u["by_entity_type"].as_object() {
594 for (kind, count) in counts_desc(by_type) {
595 lines.push(format!(" - {kind}: {count}"));
596 }
597 }
598 lines.push(String::new());
599 }
600
601 if let Some(axis) = v.get("ledger").and_then(Value::as_object) {
602 lines.push(format!("## Ledger vs files ({} folder mem(s))", axis.len()));
603 if axis.is_empty() {
604 lines.push(
605 "- no folder mems: the check does not apply to git-branch storage, whose \
606 change set is a real two-tree diff"
607 .to_string(),
608 );
609 }
610 for (mem, r) in axis {
611 let ghosts = r["ledger_without_file"]
612 .as_array()
613 .map(Vec::len)
614 .unwrap_or(0);
615 let unlogged = r["file_without_ledger"]
616 .as_array()
617 .map(Vec::len)
618 .unwrap_or(0);
619 if ghosts == 0 && unlogged == 0 {
620 lines.push(format!("- `{mem}`: ledger and files agree"));
621 continue;
622 }
623 lines.push(format!(
624 "- `{mem}`: {ghosts} recorded with no file, {unlogged} file(s) the ledger \
625 never mentions"
626 ));
627 for id in r["ledger_without_file"].as_array().into_iter().flatten() {
628 lines.push(format!(
629 " - recorded, no file: `{}`",
630 id.as_str().unwrap_or("")
631 ));
632 }
633 for id in r["file_without_ledger"].as_array().into_iter().flatten() {
634 lines.push(format!(
635 " - file, never recorded: `{}`",
636 id.as_str().unwrap_or("")
637 ));
638 }
639 }
640 lines.push(String::new());
641 }
642
643 if let Some(axis) = v.get("anchors").and_then(Value::as_object) {
644 lines.push(format!("## Anchors ({} mems)", axis.len()));
645 for (mem, counts) in axis {
646 if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
647 lines.push(format!(
648 "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
649 c["reason"].as_str().unwrap_or("reason not stated"),
650 counts["population"]
651 .as_str()
652 .unwrap_or("population not stated"),
653 ));
654 continue;
655 }
656 lines.push(format!(
657 "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
658 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
659 n(counts, "resolves"),
660 n(counts, "drifted"),
661 n(counts, "recheck"),
662 n(counts, "unresolvable"),
663 n(counts, "unobserved"),
664 n(counts, "dangling"),
665 counts["population"]
666 .as_str()
667 .unwrap_or("population not stated"),
668 ));
669 }
670 lines.push(String::new());
671 }
672
673 if let Some(axis) = v.get("vital_signs").and_then(Value::as_object) {
674 let mems: Vec<(&String, &Value)> = axis.iter().filter(|(k, _)| *k != "_item_cap").collect();
675 lines.push(format!("## Vital signs ({} mems)", mems.len()));
676 for (mem, sig) in mems {
677 let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
678 let share = match sig["type_share_by_community"]["status"].as_str() {
679 Some("declared") => format!(
680 "last-resort type `{}` over {} community(ies)",
681 sig["type_share_by_community"]["last_resort_type"]
682 .as_str()
683 .unwrap_or("?"),
684 count("type_share_by_community")
685 ),
686 _ => "last-resort type not declared".to_string(),
687 };
688 let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
689 Some("enumerated") => {
690 format!(
691 "{} unclaimed source file(s)",
692 count("unclaimed_source_files")
693 )
694 }
695 _ => "no bound source".to_string(),
696 };
697 lines.push(format!(
698 "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
699 entity(ies) in {} community(ies); {} empty declared section(s)",
700 count("contested_unowned_files"),
701 sig["zero_outgoing_entities"]["entities"]
702 .as_u64()
703 .unwrap_or(0),
704 count("zero_outgoing_entities"),
705 count("empty_declared_sections"),
706 ));
707 }
708 lines.push(String::new());
709 }
710
711 if let Some(axis) = v.get("open_questions").and_then(Value::as_object) {
712 let cap = axis
713 .get("_item_cap")
714 .and_then(Value::as_u64)
715 .unwrap_or_default();
716 lines.push(format!("## Open questions (item cap {cap} per kind)"));
717 for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
718 lines.push(format!("- `{mem}`: {} open", n(entry, "total_open")));
719 for kind in [
720 "stubs",
721 "anchors_recheck",
722 "anchors_unresolvable",
723 "anchors_unobserved",
724 "anchors_dangling",
725 "unsatisfied_constraints",
726 "dangling_links",
727 "resolution_missing",
728 "resolution_unchecked",
729 ] {
730 let count = entry[kind]["count"].as_u64().unwrap_or(0);
731 if count > 0 {
732 let more = entry[kind]["more"].as_u64().unwrap_or(0);
733 let suffix = if more > 0 {
734 format!(" ({more} more not shown)")
735 } else {
736 String::new()
737 };
738 lines.push(format!(" - {kind}: {count}{suffix}"));
739 }
740 }
741 for p in entry["process"].as_array().into_iter().flatten() {
742 if p["resolvable"] == Value::Bool(true) {
743 lines.push(format!(
744 " - process `{}`: {} open entries; {} already searched (do not redo)",
745 p["binding"].as_str().unwrap_or("?"),
746 p["open_entries"]["count"].as_u64().unwrap_or(0),
747 p["already_searched"]["count"].as_u64().unwrap_or(0),
748 ));
749 } else {
750 lines.push(format!(
751 " - process `{}`: not resolvable (mem not mounted)",
752 p["binding"].as_str().unwrap_or("?"),
753 ));
754 }
755 }
756 }
757 lines.push(String::new());
758 }
759
760 if let Some(axis) = v.get("checks").and_then(Value::as_object) {
761 lines.push(format!("## Checks ({} mems)", axis.len()));
762 for (mem, c) in axis {
763 let conf = |key: &str| c["conformance"][key].as_u64().unwrap_or(0);
764 let gate = |key: &str| c["independence"][key]["count"].as_u64().unwrap_or(0);
765 lines.push(format!(
766 "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
767 check_stale {}; conformance: never_checked {}, \
768 checked_ok {}, check_failed {}, check_stale {}; \
769 independence: self_checked {}, \
770 confirmed_independent {}, unconfirmable {}",
771 n(c, "never_checked"),
772 n(c, "checked_ok"),
773 n(c, "check_failed"),
774 n(c, "check_stale"),
775 conf("never_checked"),
776 conf("checked_ok"),
777 conf("check_failed"),
778 conf("check_stale"),
779 gate("self_checked"),
780 gate("confirmed_independent"),
781 gate("unconfirmable"),
782 ));
783 if let Some(foreign) = c.get("foreign_kinds").and_then(Value::as_object)
784 && !foreign.is_empty()
785 {
786 let listed: Vec<String> = foreign
787 .iter()
788 .map(|(k, count)| format!("{k} {}", count.as_u64().unwrap_or(0)))
789 .collect();
790 lines.push(format!(" - foreign kinds: {}", listed.join(", ")));
791 }
792 if let Some(findings) = c.get("findings").and_then(Value::as_object) {
793 for (entity, f) in findings {
794 let code = f["finding"]["code"].as_str().unwrap_or("?");
795 let section = f["finding"]["section"]
796 .as_str()
797 .map(|sec| format!(" [{sec}]"))
798 .unwrap_or_default();
799 let message = f["finding"]["message"].as_str().unwrap_or("");
800 lines.push(format!(
801 " - finding on `{entity}` ({} {}): {code}{section} — {message}",
802 f["kind"].as_str().unwrap_or("verification"),
803 f["verdict"].as_str().unwrap_or("?"),
804 ));
805 }
806 }
807 }
808 lines.push(String::new());
809 }
810
811 if let Some(axis) = v.get("signals") {
812 lines.push(format!(
813 "## Signals (notice {}, warn {})",
814 axis["counts"]["notice"].as_u64().unwrap_or(0),
815 axis["counts"]["warn"].as_u64().unwrap_or(0),
816 ));
817 for e in axis["entities"].as_array().into_iter().flatten() {
818 for sig in e["signals"].as_array().into_iter().flatten() {
819 lines.push(format!(
820 "- {} — {}: {} ({}) [{}]",
821 s(e, "id"),
822 s(sig, "name"),
823 n(sig, "value"),
824 s(sig, "level"),
825 strs(&sig["contributors"]).join(", "),
826 ));
827 }
828 }
829 lines.push(String::new());
830 }
831
832 if let Some(axis) = v.get("labelling").and_then(Value::as_object) {
833 lines.push(format!("## Labelling ({} mems)", axis.len()));
834 for (mem, m) in axis {
835 let c = &m["counts"];
836 lines.push(format!(
837 "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
838 n(c, "accepted"),
839 n(c, "defeated"),
840 n(c, "undecided"),
841 n(m, "cross_mem_edges_excluded"),
842 ));
843 for d in m["defeated"].as_array().into_iter().flatten() {
844 lines.push(format!(
845 " - defeated: {} (by {})",
846 s(d, "id"),
847 strs(&d["defeated_by"]).join(", ")
848 ));
849 }
850 for u in m["undecided"].as_array().into_iter().flatten() {
851 lines.push(format!(
852 " - undecided: {} (open attackers {})",
853 s(u, "id"),
854 strs(&u["undecided_by"]).join(", ")
855 ));
856 }
857 }
858 lines.push(String::new());
859 }
860
861 if let Some(axis) = v.get("stale_derivations").and_then(Value::as_object) {
862 let total: usize = axis
863 .values()
864 .filter_map(|a| a.as_array().map(Vec::len))
865 .sum();
866 lines.push(format!("## Stale derivations ({total} findings)"));
867 for (mem, findings) in axis {
868 for f in findings.as_array().into_iter().flatten() {
869 lines.push(format!(
870 "- `{mem}`: {} -[{}]-> {} ({})",
871 s(f, "source"),
872 s(f, "rel_type"),
873 s(f, "target"),
874 s(f, "state"),
875 ));
876 }
877 }
878 lines.push(String::new());
879 }
880
881 if let Some(items) = v.get("quarantined").and_then(Value::as_array) {
882 lines.push(format!("## Quarantined mems ({})", items.len()));
883 for q in items {
884 lines.push(format!(
885 "- `{}` [{}] {}",
886 s(q, "mem"),
887 s(q, "reason_code"),
888 s(q, "reason_message"),
889 ));
890 }
891 lines.push(String::new());
892 }
893
894 if let Some(items) = v.get("load_errors").and_then(Value::as_array) {
895 lines.push(format!("## Load errors ({})", items.len()));
896 for e in items {
897 lines.push(format!("- `{}` — {}", s(e, "file"), s(e, "error")));
898 }
899 lines.push(String::new());
900 }
901
902 if let Some(f) = v.get("friction") {
903 lines.push(format!(
904 "## Friction ({} refusals recorded, {} in the last 24h)",
905 n(f, "total"),
906 f["recent_24h"]["total"].as_u64().unwrap_or(0),
907 ));
908 if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
909 lines.push("- by code:".to_string());
910 for (code, count) in counts_desc(by_code) {
911 lines.push(format!(" - {code}: {count}"));
912 if let Some(reasons) = f["by_reason"][code.as_str()]
913 .as_object()
914 .filter(|m| !m.is_empty())
915 {
916 for (reason, count) in counts_desc(reasons) {
917 lines.push(format!(" - {reason}: {count}"));
918 }
919 }
920 }
921 }
922 if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
923 lines.push("- by verb:".to_string());
924 for (verb, count) in counts_desc(by_verb) {
925 lines.push(format!(" - {verb}: {count}"));
926 }
927 }
928 lines.push(String::new());
929 }
930
931 lines.join("\n")
932}
933
934fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
939 if !strict || violations.is_empty() {
940 return Ok(());
941 }
942 let summary = violations
943 .iter()
944 .map(|(code, n)| format!("{code}: {n}"))
945 .collect::<Vec<_>>()
946 .join(", ");
947 Err(crate::CliError::new(
948 ExitKind::Generic,
949 "HEALTH_STRICT_VIOLATIONS",
950 format!("strict mode: tier-2 violations present ({summary})"),
951 )
952 .into())
953}
954
955fn stale_line(item: &Value) -> String {
958 let base = format!(
959 "- {} — {} ({} days)",
960 s(item, "id"),
961 s(item, "title"),
962 n(item, "days_since_modified")
963 );
964 match item.get("anchor_state").and_then(Value::as_str) {
965 Some(state) => format!("{base} (anchor clock: {state})"),
966 None => base,
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use clap::CommandFactory;
974 use memstead_base::ops::health::HEALTH_INCLUDE_KEYS;
975
976 #[test]
977 fn help_lists_every_include_key() {
978 let cmd = Args::command();
979 let arg = cmd
980 .get_arguments()
981 .find(|a| a.get_id() == "include")
982 .expect("--include arg must exist");
983 let help = arg
984 .get_help()
985 .expect("--include must have help text")
986 .to_string();
987 for key in HEALTH_INCLUDE_KEYS {
988 assert!(
989 help.contains(key),
990 "`memstead health --help` must name include key `{key}` (got: {help})"
991 );
992 }
993 }
994
995 #[test]
996 fn strict_reads_the_tier_two_sections_off_the_report() {
997 let v = serde_json::json!({
998 "findings": [
999 {"code": "UNRESOLVED_STUB"},
1000 {"code": "DANGLING_LINK_TARGET_MISSING"},
1001 {"code": "CROSS_MEM_EDGE_UNGRANTED"},
1002 ],
1003 "constraints": [{"id": "a"}],
1004 "signals": {"counts": {"warn": 2}},
1005 "warnings": [{"code": "MOUNT_UNBACKED"}],
1006 });
1007 let include = vec!["integrity".to_string(), "constraints".to_string()];
1008 let got = strict_violations(&v, &include);
1009 assert_eq!(
1010 got,
1011 vec![
1012 ("constraints", 1),
1013 ("dangling_links", 1),
1014 ("unresolved_stubs", 1),
1015 ("ungranted_cross_mem_edges", 1),
1016 ("signals", 2),
1017 ("mount_unbacked", 1),
1018 ]
1019 );
1020 let got = strict_violations(&v, &[]);
1022 assert_eq!(got, vec![("signals", 2), ("mount_unbacked", 1)]);
1023 }
1024}